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
6 changes: 6 additions & 0 deletions .changeset/fresh-tools-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@emdash-cms/plugin-test": minor
"@emdash-cms/plugin-cli": minor
---

Adds a workerd-backed Vitest host for sandboxed plugin tests and includes it in projects created by `emdash-plugin init`. `emdashPluginTest()` builds the plugin and configures D1, Worker Loader, and the production `PluginBridge`; `createPluginTestHost()` invokes hooks and routes through the production sandbox boundary and provides helpers for content fixtures, plugin storage, and KV assertions.
2 changes: 1 addition & 1 deletion apps/aggregator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
},
"devDependencies": {
"@cloudflare/vite-plugin": "catalog:",
"@cloudflare/vitest-pool-workers": "catalog:",
"@cloudflare/vitest-plugin": "catalog:",
"@emdash-cms/atproto-test-utils": "workspace:*",
"@types/node": "catalog:",
"typescript": "catalog:",
Expand Down
2 changes: 1 addition & 1 deletion apps/aggregator/test/pds-verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* would re-implement what `@atcute/repo` already tests internally, and the
* consumer-level test path stubs verification via `ConsumerDeps.verify`
* (the FakePublisher / MockPds fixture from `@emdash-cms/atproto-test-utils`
* can't load inside `@cloudflare/vitest-pool-workers` due to a transitive
* can't load inside `@cloudflare/vitest-plugin` due to a transitive
* `@atproto/lex-data` incompatibility; see records-consumer test header).
*
* What we DO test here is the surface every reason code can be reached
Expand Down
2 changes: 1 addition & 1 deletion apps/aggregator/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// `wrangler types`) — referenced via `include` below. The vitest-pool
// types subpath provides the `cloudflare:test` ambient declarations
// for the smoke test rig.
"types": ["@cloudflare/vitest-pool-workers/types"],
"types": ["@cloudflare/vitest-plugin/types"],
"verbatimModuleSyntax": true,
"noEmit": true
},
Expand Down
2 changes: 1 addition & 1 deletion apps/aggregator/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* us HMR + proper module resolution, `vite build` produces the deployable
* bundle that `wrangler deploy` ships.
*
* Test config is separate: `vitest.config.ts` uses `@cloudflare/vitest-pool-workers`,
* Test config is separate: `vitest.config.ts` uses `@cloudflare/vitest-plugin`,
* which manages its own miniflare instance. The two pipelines don't share
* configuration but read the same `wrangler.jsonc` for binding shape, so the
* test environment matches dev/prod by construction.
Expand Down
4 changes: 2 additions & 2 deletions apps/aggregator/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Aggregator test config.
*
* Uses `@cloudflare/vitest-pool-workers` (v0.16+) so tests run inside a real
* Uses `@cloudflare/vitest-plugin` so tests run inside a real
* workerd isolate with real D1, real DOs, and real Queues. The
* `cloudflareTest` plugin reads `wrangler.jsonc` for binding shape, so the
* test environment matches dev/prod by construction.
Expand All @@ -22,7 +22,7 @@

import { fileURLToPath } from "node:url";

import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-pool-workers";
import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-plugin";
import { defineConfig } from "vitest/config";

const migrationsPath = fileURLToPath(new URL("./migrations", import.meta.url));
Expand Down
2 changes: 1 addition & 1 deletion apps/release-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
},
"devDependencies": {
"@cloudflare/vite-plugin": "catalog:",
"@cloudflare/vitest-pool-workers": "catalog:",
"@cloudflare/vitest-plugin": "catalog:",
"@playwright/test": "^1.61.1",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/react": "^16.3.0",
Expand Down
2 changes: 1 addition & 1 deletion apps/release-service/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["@cloudflare/vitest-pool-workers/types", "node"],
"types": ["@cloudflare/vitest-plugin/types", "node"],
"verbatimModuleSyntax": true,
"noEmit": true
},
Expand Down
2 changes: 1 addition & 1 deletion apps/release-service/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
import { cloudflareTest } from "@cloudflare/vitest-plugin";
import { configDefaults, defineConfig } from "vitest/config";

import { TEST_ACCESS_AUDIENCES, TEST_ASSERTION_KEYSET } from "./test/fixtures/oauth.js";
Expand Down
2 changes: 1 addition & 1 deletion apps/release-service/vitest.encryption-v2.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
import { cloudflareTest } from "@cloudflare/vitest-plugin";
import { defineConfig } from "vitest/config";

import { TEST_ACCESS_AUDIENCES, TEST_ASSERTION_KEYSET } from "./test/fixtures/oauth.js";
Expand Down
2 changes: 1 addition & 1 deletion apps/release-verifier/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
},
"devDependencies": {
"@cloudflare/vite-plugin": "catalog:",
"@cloudflare/vitest-pool-workers": "catalog:",
"@cloudflare/vitest-plugin": "catalog:",
"@types/node": "catalog:",
"modern-tar": "^0.7.6",
"typescript": "catalog:",
Expand Down
2 changes: 1 addition & 1 deletion apps/release-verifier/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["@cloudflare/vitest-pool-workers/types", "node"],
"types": ["@cloudflare/vitest-plugin/types", "node"],
"verbatimModuleSyntax": true,
"noEmit": true
},
Expand Down
2 changes: 1 addition & 1 deletion apps/release-verifier/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
import { cloudflareTest } from "@cloudflare/vitest-plugin";
import { defineConfig } from "vitest/config";

export default defineConfig({
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/plugins/creating-plugins/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Create a new plugin with `init`:
npx @emdash-cms/plugin-cli init my-plugin
```

This scaffolds `emdash-plugin.jsonc`, `src/plugin.ts`, `package.json`, `tsconfig.json`, a test, a README, `AGENTS.md`, a local `creating-plugins` skill, and package-manager configuration. `.agents/skills` and `.claude/skills` link to the canonical `skills` directory, and `.claude/CLAUDE.md` links to `AGENTS.md`, so Codex and Claude use the same project guidance. The source starts with one route assigned to a `SandboxedPlugin`-typed constant and exported as default.
This scaffolds `emdash-plugin.jsonc`, `src/plugin.ts`, `package.json`, `tsconfig.json`, `vitest.config.ts`, a workerd-backed test, a README, `AGENTS.md`, a local `creating-plugins` skill, and package-manager configuration. `.agents/skills` and `.claude/skills` link to the canonical `skills` directory, and `.claude/CLAUDE.md` links to `AGENTS.md`, so Codex and Claude use the same project guidance. The source starts with one route assigned to a `SandboxedPlugin`-typed constant and exported as default. The test invokes that route through EmDash's production sandbox wrapper and host bridge.

Interactive setup asks for the publisher, author, security contact, and source repository, then shows the complete project summary before writing. Required fields cannot be skipped.

Expand Down
110 changes: 110 additions & 0 deletions docs/src/content/docs/plugins/creating-plugins/testing.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
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`.

Projects created by `emdash-plugin init` include this setup. Existing plugin projects can install the test host as a development dependency:

```sh
pnpm add -D @emdash-cms/plugin-test vitest
```

If the project restricts dependency build scripts, allow `workerd` to install its platform binary. The generated pnpm policy includes this entry:

```yaml title="pnpm-workspace.yaml"
allowBuilds:
workerd: true
```

## Configure Vitest

Add the EmDash test plugin to the project's Vitest configuration:

```ts title="vitest.config.ts"
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 a route

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

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

## 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:

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

Create a collection and seed entries before invoking a route or hook that reads site content:

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

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 @@ -27,31 +27,32 @@ You need:
pnpm dlx @emdash-cms/plugin-cli init save-log
```

The command asks for the publisher, author, security contact, and source repository, then shows a project summary before creating this structure:
The command asks for the publisher, author, security contact, and source repository, then shows a project summary before creating this structure:

```text
save-log/
├── .agents/
│ └── skills -> ../skills
├── .claude/
│ ├── CLAUDE.md -> ../AGENTS.md
│ └── skills -> ../skills
├── AGENTS.md
├── emdash-plugin.jsonc
├── package.json
├── pnpm-workspace.yaml
save-log/
├── .agents/
│ └── skills -> ../skills
├── .claude/
│ ├── CLAUDE.md -> ../AGENTS.md
│ └── skills -> ../skills
├── AGENTS.md
├── emdash-plugin.jsonc
├── package.json
├── pnpm-workspace.yaml
├── README.md
├── skills/
│ └── creating-plugins/SKILL.md
├── src/
│ └── plugin.ts
├── tests/
│ └── plugin.test.ts
├── tsconfig.json
├── README.md
├── skills/
│ └── creating-plugins/SKILL.md
├── vitest.config.ts
└── .gitignore
```

2. Install the generated package's dependencies.
2. Install the generated package's dependencies.

```sh
cd save-log
Expand Down Expand Up @@ -134,41 +135,40 @@ Hook handlers receive `(event, ctx)`. Route handlers receive `(routeCtx, ctx)`.

## Update the generated test

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

```typescript title="tests/plugin.test.ts"
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";

import plugin from "../src/plugin.js";
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 running plugin", async () => {
const route = plugin.routes?.health;
if (!route || typeof route !== "object" || !("handler" in route)) {
throw new Error("health route handler not found");
}

const result = await route.handler({} as never, makeTestContext());
expect(result).toEqual({ ok: true, plugin: "save-log" });
host = await createPluginTestHost();
const result = await host.invokeRoute("health");
expect(result).toEqual({ ok: true, plugin: "save-log" });
});
});

function makeTestContext() {
return {
plugin: { id: "save-log", version: "0.1.0" },
} as unknown as import("emdash").PluginContext;
}
```

The test builds the plugin and invokes the route through Worker Loader and `PluginBridge`. The [sandboxed plugin testing guide](/plugins/creating-plugins/testing/) covers hooks, content fixtures, storage assertions, and the limits of local workerd tests.

## Validate and build

Run the generated test, validate the manifest, and build the npm artifacts.

```sh
pnpm run validate
pnpm run typecheck
pnpm run test
pnpm run build
pnpm run validate
pnpm run typecheck
pnpm run test
pnpm run build
```

The build creates:
Expand Down Expand Up @@ -243,4 +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.
- [Bundling and publishing](/plugins/creating-plugins/publishing/) publishes the plugin to the registry.
2 changes: 1 addition & 1 deletion infra/emdash-bot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.53.0",
"@cloudflare/vitest-pool-workers": "catalog:",
"@cloudflare/vitest-plugin": "catalog:",
"@flue/vite": "2.0.3",
"typescript": "catalog:",
"vite": "8.0.11",
Expand Down
2 changes: 1 addition & 1 deletion infra/emdash-bot/tests/integration/orchestrator.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Workers-pool integration tests for OrchestratorDO.
//
// These run inside a real workerd isolate via @cloudflare/vitest-pool-workers,
// These run inside a real workerd isolate via @cloudflare/vitest-plugin,
// so `env.Orchestrator` is the actual DO namespace, storage is real (sqlite
// inside miniflare), and lifecycle semantics (single-threaded per instance,
// blockConcurrencyWhile, etc.) match production.
Expand Down
2 changes: 1 addition & 1 deletion infra/emdash-bot/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"module": "preserve",
"moduleResolution": "bundler",
"lib": ["ES2023"],
"types": ["@cloudflare/vitest-pool-workers/types", "vite/client"],
"types": ["@cloudflare/vitest-plugin/types", "vite/client"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
Expand Down
2 changes: 1 addition & 1 deletion infra/emdash-bot/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Pure-vitest config for tests/unit/. Anything that needs bindings (DOs, AI,
// Sandbox) goes in tests/integration/ and runs under vitest.workers.config.ts
// against @cloudflare/vitest-pool-workers.
// against @cloudflare/vitest-plugin.
import { defineConfig } from "vitest/config";

export default defineConfig({
Expand Down
Loading
Loading