Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/runtime-plugin-test-host.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@emdash-cms/plugin-test": minor
"emdash": minor
"@emdash-cms/cloudflare": patch
"@emdash-cms/plugin-cli": patch
---

Adds `createPluginRuntimeTestHost()` for sandboxed plugin tests that must exercise EmDash orchestration instead of invoking an isolate directly. The host separates direct transport calls, fixtures, production actions, observable-state inspectors, scheduled time control, cold restart, and disposal.

Runtime actions cover the shipped content lifecycle, plugin activation and deactivation, media upload, public comment submission, comment moderation, plugin-route policy, and scheduled task execution. The controlled scheduler clock applies to cron tasks and scheduled publishing. `restart()` retains D1, plugin storage, media storage, and plugin state while replacing runtime and isolate memory. The host captures delivered email for assertions.

`createPluginTestHost()` and its top-level `invokeHook()` and `invokeRoute()` methods remain compatible for fast transport-level tests. `emdashPluginTest()` supplies the runtime modules required by the documented Vitest configuration. Generated plugin projects continue to use Worker Loader by default and describe Node/workerd parity as an opt-in test for runner-sensitive behavior.
5 changes: 5 additions & 0 deletions .changeset/scheduled-iso-one-shot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes ISO date-time plugin schedules being treated as recurring tasks when the cron parser accepts the timestamp. A successful one-shot task is now removed after it runs.
70 changes: 67 additions & 3 deletions docs/src/content/docs/plugins/creating-plugins/testing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ title: Test sandboxed plugins
description: Run plugin hooks and routes through EmDash's production sandbox boundary in Vitest.
---

`@emdash-cms/plugin-test` builds a sandboxed plugin and runs its tests inside workerd. Tests use EmDash's production Cloudflare sandbox wrapper and `PluginBridge`, with local D1 and Worker Loader bindings supplied by `@cloudflare/vitest-plugin`.
`@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:

Expand Down Expand Up @@ -33,7 +38,7 @@ export default defineConfig({

`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 a route
## Test sandbox transport

Create and dispose a host inside each test. Disposal stops the plugin and resets its test bindings:

Expand Down Expand Up @@ -63,6 +68,8 @@ describe("health route", () => {

`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

Invoke hooks with the event shape they receive from EmDash. The storage and KV readers inspect the state written through the bridge:
Expand Down Expand Up @@ -103,8 +110,65 @@ 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

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:

```ts title="tests/plugin.test.ts"
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:

- `transport` directly invokes the isolate for transport-level checks.
- `fixtures` creates site, collection, field, user, content, and plugin state without firing hooks.
- `actions` runs content state changes, plugin activation and deactivation, media uploads, public comment submission, comment moderation, and policy-checked plugin routes.
- `inspect` reads content, plugin storage, KV, settings, plugin state, scheduled tasks, media, comments, and captured email.
- `scheduled` controls the effective time for cron tasks and scheduled publishing, then runs one production maintenance batch.
- `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.

The runtime host exposes shipped operations only. Capability-specific helpers for translations, publication policy, Block Kit interactions, binary HTTP, raw routes, taxonomies, redirects, expanded comment administration, media bytes, and encrypted settings belong to the releases that add those capabilities.

## Test boundaries

The host covers the built plugin, isolate boundary, remote procedure call serialization, D1 behavior, capability checks, hooks, routes, KV, and declared storage. It does not render the EmDash admin application or reproduce 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.
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, invoke the plugin's `admin` route and assert the returned block document. Use the [Block Playground](https://emdash-blocks.cto.cloudflare.dev/) or a browser journey to verify the rendered layout and interactions.
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ Hook handlers receive `(event, ctx)`. Route handlers receive `(routeCtx, ctx)`.

## Update the generated test

The scaffolded test runs the original `hello` route through the workerd-backed plugin host. Replace it with a test for the `health` route:
The scaffolded test invokes the original `hello` route through the Worker Loader transport host. Replace it with a test for the `health` route:

```typescript title="tests/plugin.test.ts"
import { afterEach, describe, expect, it } from "vitest";
Expand Down Expand Up @@ -243,5 +243,5 @@ Save an entry in the EmDash admin. The site log contains `Content save recorded`
- [Block Kit](/plugins/creating-plugins/block-kit/) adds an admin page without shipping browser JavaScript.
- [Settings](/plugins/creating-plugins/settings/) stores site-specific plugin configuration.
- [Storage](/plugins/creating-plugins/storage/) covers indexed queries and pagination.
- [Testing](/plugins/creating-plugins/testing/) runs hooks and routes through the production sandbox boundary.
- [Testing](/plugins/creating-plugins/testing/) covers direct sandbox transport tests and runtime-backed host-action tests.
- [Bundling and publishing](/plugins/creating-plugins/publishing/) publishes the plugin to the registry.
12 changes: 10 additions & 2 deletions packages/cloudflare/src/sandbox/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const FILE_EXT_REGEX = /^\.[a-z0-9]{1,10}$/i;
*/
let emailSendCallback: SandboxEmailSendCallback | null = null;
let cronRescheduleCallback: (() => void) | null = null;
let cronNowCallback: (() => Date) | null = null;

/**
* Set the email send callback for all bridge instances.
Expand All @@ -86,6 +87,10 @@ export function setCronRescheduleCallback(callback: (() => void) | null): void {
cronRescheduleCallback = callback;
}

export function setCronNowCallback(callback: (() => Date) | null): void {
cronNowCallback = callback;
}

function serializeValue(value: unknown): unknown {
if (value === null || value === undefined) return null;
if (typeof value === "boolean") return value ? 1 : 0;
Expand Down Expand Up @@ -1255,8 +1260,11 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
opts: { schedule: string; data?: Record<string, unknown> },
): Promise<void> {
const db = new Kysely<Database>({ dialect: new D1Dialect({ database: this.env.DB }) });
await new CronAccessImpl(db, this.ctx.props.pluginId, () =>
cronRescheduleCallback?.(),
await new CronAccessImpl(
db,
this.ctx.props.pluginId,
() => cronRescheduleCallback?.(),
cronNowCallback ?? undefined,
).schedule(name, opts);
}

Expand Down
9 changes: 7 additions & 2 deletions packages/cloudflare/src/sandbox/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
type I18nConfig,
} from "emdash";

import { setCronRescheduleCallback, setEmailSendCallback } from "./bridge.js";
import { setCronNowCallback, setCronRescheduleCallback, setEmailSendCallback } from "./bridge.js";
import type { WorkerLoader, WorkerStub, PluginBridgeBinding, WorkerLoaderLimits } from "./types.js";
import { generatePluginWrapper } from "./wrapper.js";

Expand Down Expand Up @@ -121,6 +121,7 @@ export class CloudflareSandboxRunner implements SandboxRunner {

// Wire email send callback if provided at construction time
setEmailSendCallback(options.emailSend ?? null);
setCronNowCallback(options.now ?? null);
}

/**
Expand Down Expand Up @@ -192,6 +193,7 @@ export class CloudflareSandboxRunner implements SandboxRunner {
pluginBridge,
this.resolvedLimits,
this.siteInfo,
this.options.isolateKey,
);

this.plugins.set(pluginId, plugin);
Expand Down Expand Up @@ -230,6 +232,7 @@ class CloudflareSandboxedPlugin implements SandboxedPluginInstance {
locale: string;
trailingSlash?: "always" | "never" | "ignore";
};
private workerName: string;

constructor(
manifest: PluginManifest,
Expand All @@ -243,8 +246,10 @@ class CloudflareSandboxedPlugin implements SandboxedPluginInstance {
locale: string;
trailingSlash?: "always" | "never" | "ignore";
},
isolateKey?: string,
) {
this.id = `${manifest.id}:${manifest.version}`;
this.workerName = isolateKey ? `${this.id}:${isolateKey}` : this.id;
this.manifest = manifest;
this.code = code;
this.loader = loader;
Expand Down Expand Up @@ -298,7 +303,7 @@ class CloudflareSandboxedPlugin implements SandboxedPluginInstance {

// Get a fresh stub with the new bridge binding.
// Worker Loader caches the isolate but the stub/bindings are per-call.
return this.loader.get(this.id, () => ({
return this.loader.get(this.workerName, () => ({
compatibilityDate: "2026-04-01",
mainModule: "plugin.js",
modules: {
Expand Down
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
"./plugin": {
"types": "./dist/plugin-types.d.mts"
},
"./plugin-test-runtime": {
"types": "./dist/plugin-test-runtime.d.mts",
"default": "./dist/plugin-test-runtime.mjs"
},
"./middleware": {
"types": "./dist/astro/middleware.d.mts",
"default": "./dist/astro/middleware.mjs"
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/api/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1538,6 +1538,7 @@ export async function handleContentSchedule(
collection: string,
id: string,
scheduledAt: string,
currentTime: Date = new Date(),
): Promise<ApiResult<ContentResponse>> {
try {
const item = await withTransaction(db, async (trx) => {
Expand All @@ -1548,7 +1549,7 @@ export async function handleContentSchedule(
const publishConfig = await getCollectionPublishConfig(trx, collection);
requireRoutablePublishSlug(publishConfig.routable, existing.slug);
}
return repo.schedule(collection, resolvedId, scheduledAt);
return repo.schedule(collection, resolvedId, scheduledAt, currentTime);
});

const hasSeo = await collectionHasSeo(db, collection);
Expand Down Expand Up @@ -1637,6 +1638,7 @@ export async function handleContentPublish(
requireScheduledDue?: boolean;
expectedScheduledAt?: string;
_rev?: string;
currentTime?: Date;
} = {},
): Promise<ApiResult<ContentResponse>> {
try {
Expand All @@ -1662,6 +1664,7 @@ export async function handleContentPublish(
publishConfig.supportsRevisions,
publishConfig.routable,
expectedRevision,
options.currentTime,
);

if (
Expand Down
35 changes: 31 additions & 4 deletions packages/core/src/api/handlers/media-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ export type MediaUploadResult = ApiResult<{
deduplicated?: boolean;
}>;

export interface MediaUploadHooks {
beforeUpload?(file: {
name: string;
type: string;
size: number;
}): Promise<{ name: string; type: string; size: number }>;
}

function fail(code: string, message: string): MediaUploadResult {
return { success: false, error: { code, message } };
}
Expand Down Expand Up @@ -136,6 +144,7 @@ export async function handleMediaUpload(
db: Kysely<Database>,
storage: Storage,
input: MediaUploadInput,
hooks: MediaUploadHooks = {},
): Promise<MediaUploadResult> {
if (!input.base64 === !input.url) {
return fail("VALIDATION_ERROR", "Provide exactly one of 'base64' or 'url'");
Expand All @@ -158,7 +167,9 @@ export async function handleMediaUpload(
if (!CONTENT_TYPE_RE.test(acquired.mimeType)) {
return fail("VALIDATION_ERROR", "Invalid content type");
}
const mimeType = normalizeMime(acquired.mimeType);
let filename = input.filename;
let mimeType = normalizeMime(acquired.mimeType);
let size = bytes.byteLength;

if (!matchesMimeAllowlist(mimeType, GLOBAL_UPLOAD_ALLOWLIST)) {
return fail("INVALID_TYPE", "File type not allowed");
Expand All @@ -168,6 +179,22 @@ export async function handleMediaUpload(
}

try {
if (hooks.beforeUpload) {
const processed = await hooks.beforeUpload({
name: filename,
type: mimeType,
size: bytes.byteLength,
});
filename = processed.name;
mimeType = normalizeMime(processed.type);
size = processed.size;
if (!CONTENT_TYPE_RE.test(processed.type)) {
return fail("VALIDATION_ERROR", "Invalid content type");
}
if (!matchesMimeAllowlist(mimeType, GLOBAL_UPLOAD_ALLOWLIST)) {
return fail("INVALID_TYPE", "File type not allowed");
}
}
const contentHash = await computeContentHash(bytes);
const repo = new MediaRepository(db);

Expand All @@ -176,15 +203,15 @@ export async function handleMediaUpload(
return { success: true, data: { item: withUrl(existing), deduplicated: true } };
}

const storageKey = `${ulid()}${path.extname(input.filename)}`;
const storageKey = `${ulid()}${path.extname(filename)}`;
await storage.upload({ key: storageKey, body: bytes, contentType: mimeType });

try {
const enriched = await enrichImageMetadata(bytes, mimeType);
const item = await repo.create({
filename: input.filename,
filename,
mimeType,
size: bytes.byteLength,
size,
width: enriched.width,
height: enriched.height,
alt: input.alt,
Expand Down
17 changes: 2 additions & 15 deletions packages/core/src/astro/routes/api/admin/plugins/[id]/disable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ import type { APIRoute } from "astro";

import { requirePerm } from "#api/authorize.js";
import { apiError, unwrapResult } from "#api/error.js";
import { handlePluginDisable } from "#api/index.js";
import { checkMediaUsageActivationWriteFence } from "#api/media-usage-write-fence.js";
import { setCronTasksEnabled } from "#plugins/cron.js";
import { disableRuntimePlugin } from "#plugins/lifecycle.js";

export const prerender = false;

Expand All @@ -32,17 +31,5 @@ export const POST: APIRoute = async ({ params, locals }) => {
return apiError("INVALID_REQUEST", "Plugin ID required", 400);
}

const result = await handlePluginDisable(
emdash.db,
emdash.configuredPlugins,
emdash.sandboxedPluginEntries,
id,
);

if (!result.success) return unwrapResult(result);

await emdash.setPluginStatus(id, "inactive");
await setCronTasksEnabled(emdash.db, id, false);

return unwrapResult(result);
return unwrapResult(await disableRuntimePlugin(emdash, id));
};
Loading
Loading