diff --git a/.changeset/fresh-tools-test.md b/.changeset/fresh-tools-test.md new file mode 100644 index 0000000000..ad72b6467c --- /dev/null +++ b/.changeset/fresh-tools-test.md @@ -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. diff --git a/apps/aggregator/package.json b/apps/aggregator/package.json index 5e7f8ac93f..df90c65f8c 100644 --- a/apps/aggregator/package.json +++ b/apps/aggregator/package.json @@ -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:", diff --git a/apps/aggregator/test/pds-verify.test.ts b/apps/aggregator/test/pds-verify.test.ts index 68c2055d10..7135c586d4 100644 --- a/apps/aggregator/test/pds-verify.test.ts +++ b/apps/aggregator/test/pds-verify.test.ts @@ -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 diff --git a/apps/aggregator/tsconfig.json b/apps/aggregator/tsconfig.json index bbd1811ae8..b0c4f0df20 100644 --- a/apps/aggregator/tsconfig.json +++ b/apps/aggregator/tsconfig.json @@ -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 }, diff --git a/apps/aggregator/vite.config.ts b/apps/aggregator/vite.config.ts index f1846da23f..0669da64ff 100644 --- a/apps/aggregator/vite.config.ts +++ b/apps/aggregator/vite.config.ts @@ -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. diff --git a/apps/aggregator/vitest.config.ts b/apps/aggregator/vitest.config.ts index 01845e36ea..5837f00bf7 100644 --- a/apps/aggregator/vitest.config.ts +++ b/apps/aggregator/vitest.config.ts @@ -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. @@ -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)); diff --git a/apps/release-service/package.json b/apps/release-service/package.json index c8fa8ccd91..f0fcb2eb2a 100644 --- a/apps/release-service/package.json +++ b/apps/release-service/package.json @@ -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", diff --git a/apps/release-service/tsconfig.json b/apps/release-service/tsconfig.json index 7c026bcb1f..a46a45baa1 100644 --- a/apps/release-service/tsconfig.json +++ b/apps/release-service/tsconfig.json @@ -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 }, diff --git a/apps/release-service/vitest.config.ts b/apps/release-service/vitest.config.ts index ecc8ea7895..513547f5fd 100644 --- a/apps/release-service/vitest.config.ts +++ b/apps/release-service/vitest.config.ts @@ -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"; diff --git a/apps/release-service/vitest.encryption-v2.config.ts b/apps/release-service/vitest.encryption-v2.config.ts index d6836807f4..d64fce7689 100644 --- a/apps/release-service/vitest.encryption-v2.config.ts +++ b/apps/release-service/vitest.encryption-v2.config.ts @@ -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"; diff --git a/apps/release-verifier/package.json b/apps/release-verifier/package.json index dbad7311b9..b7bf5b7391 100644 --- a/apps/release-verifier/package.json +++ b/apps/release-verifier/package.json @@ -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:", diff --git a/apps/release-verifier/tsconfig.json b/apps/release-verifier/tsconfig.json index 7dcabbcc62..1d9a4961ed 100644 --- a/apps/release-verifier/tsconfig.json +++ b/apps/release-verifier/tsconfig.json @@ -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 }, diff --git a/apps/release-verifier/vitest.config.ts b/apps/release-verifier/vitest.config.ts index 5bcb819928..e658710906 100644 --- a/apps/release-verifier/vitest.config.ts +++ b/apps/release-verifier/vitest.config.ts @@ -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({ diff --git a/docs/src/content/docs/plugins/creating-plugins/cli.mdx b/docs/src/content/docs/plugins/creating-plugins/cli.mdx index dfd3008bc1..e69116cdb0 100644 --- a/docs/src/content/docs/plugins/creating-plugins/cli.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/cli.mdx @@ -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. diff --git a/docs/src/content/docs/plugins/creating-plugins/testing.mdx b/docs/src/content/docs/plugins/creating-plugins/testing.mdx new file mode 100644 index 0000000000..347a31e3be --- /dev/null +++ b/docs/src/content/docs/plugins/creating-plugins/testing.mdx @@ -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. diff --git a/docs/src/content/docs/plugins/creating-plugins/your-first-plugin.mdx b/docs/src/content/docs/plugins/creating-plugins/your-first-plugin.mdx index 31dd78d0d6..5dcecb0003 100644 --- a/docs/src/content/docs/plugins/creating-plugins/your-first-plugin.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/your-first-plugin.mdx @@ -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 @@ -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: @@ -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. diff --git a/infra/emdash-bot/package.json b/infra/emdash-bot/package.json index 888312faea..1cbe163db6 100644 --- a/infra/emdash-bot/package.json +++ b/infra/emdash-bot/package.json @@ -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", diff --git a/infra/emdash-bot/tests/integration/orchestrator.test.ts b/infra/emdash-bot/tests/integration/orchestrator.test.ts index 681265acf5..8d8148961e 100644 --- a/infra/emdash-bot/tests/integration/orchestrator.test.ts +++ b/infra/emdash-bot/tests/integration/orchestrator.test.ts @@ -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. diff --git a/infra/emdash-bot/tsconfig.json b/infra/emdash-bot/tsconfig.json index 0de3a55b04..2ef884195e 100644 --- a/infra/emdash-bot/tsconfig.json +++ b/infra/emdash-bot/tsconfig.json @@ -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, diff --git a/infra/emdash-bot/vitest.config.ts b/infra/emdash-bot/vitest.config.ts index 1ca07ba470..d1b0628bb6 100644 --- a/infra/emdash-bot/vitest.config.ts +++ b/infra/emdash-bot/vitest.config.ts @@ -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({ diff --git a/infra/emdash-bot/vitest.workers.config.ts b/infra/emdash-bot/vitest.workers.config.ts index ff81e16b14..ecf92da8f6 100644 --- a/infra/emdash-bot/vitest.workers.config.ts +++ b/infra/emdash-bot/vitest.workers.config.ts @@ -2,7 +2,7 @@ * Workers-pool test config. * * Runs tests under tests/integration/ inside a real workerd isolate via - * `@cloudflare/vitest-pool-workers`. Bindings (Sandbox, OrchestratorDO, AI, R2, + * `@cloudflare/vitest-plugin`. Bindings (Sandbox, OrchestratorDO, AI, R2, * KV-equivalent DO storage) come from wrangler.jsonc -- the same config dev and * prod read -- so tests exercise the same shapes as the deployed Worker. * @@ -22,7 +22,7 @@ * real Workers AI live in a separate suite. */ -import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { cloudflareTest } from "@cloudflare/vitest-plugin"; import { defineConfig, type Plugin } from "vitest/config"; // The Flue Vite plugin transforms `SKILL.md` directory imports into skill diff --git a/infra/emdash-bot/wrangler.test.jsonc b/infra/emdash-bot/wrangler.test.jsonc index 2495d65b23..c95cf2031d 100644 --- a/infra/emdash-bot/wrangler.test.jsonc +++ b/infra/emdash-bot/wrangler.test.jsonc @@ -1,5 +1,5 @@ { - // Test-only wrangler config consumed by @cloudflare/vitest-pool-workers via + // Test-only wrangler config consumed by @cloudflare/vitest-plugin via // vitest.workers.config.ts. Mirrors production bindings for the // user-owned DOs (Sandbox, OrchestratorDO) and the R2 surface but // skips the Flue-generated workflow DOs (FlueRegistry, FlueClassify- diff --git a/packages/core/package.json b/packages/core/package.json index 4d39eb7e50..fa959f1722 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -301,7 +301,7 @@ "devDependencies": { "@apidevtools/swagger-parser": "^12.1.0", "@arethetypeswrong/cli": "catalog:", - "@cloudflare/vitest-pool-workers": "catalog:", + "@cloudflare/vitest-plugin": "catalog:", "@emdash-cms/atproto-test-utils": "workspace:*", "@emdash-cms/blocks": "workspace:*", "@emdash-cms/registry-verification": "workspace:*", diff --git a/packages/core/vitest.workerd.config.ts b/packages/core/vitest.workerd.config.ts index 6f22f6aab4..c2ff06e054 100644 --- a/packages/core/vitest.workerd.config.ts +++ b/packages/core/vitest.workerd.config.ts @@ -1,4 +1,4 @@ -import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { cloudflareTest } from "@cloudflare/vitest-plugin"; import { defineConfig } from "vitest/config"; const virtualStubs: Record = { diff --git a/packages/plugin-cli/README.md b/packages/plugin-cli/README.md index 93c3a5535e..ed0a35a888 100644 --- a/packages/plugin-cli/README.md +++ b/packages/plugin-cli/README.md @@ -10,7 +10,7 @@ CLI for authoring, building, and publishing EmDash plugins. npx @emdash-cms/plugin-cli init my-plugin ``` -Interactive setup collects the required publisher, author, and security metadata, detects the invoking package manager, and shows a project summary before writing. The scaffold includes `AGENTS.md`, a canonical `skills/creating-plugins` skill shared through `.agents/skills` and `.claude/skills` symlinks, a Claude instruction link, package scripts for every validation and publishing command, and pnpm build-script policy when pnpm is selected. +Interactive setup collects the required publisher, author, and security metadata, detects the invoking package manager, and shows a project summary before writing. The scaffold includes a workerd-backed Vitest host, `AGENTS.md`, a canonical `skills/creating-plugins` skill shared through `.agents/skills` and `.claude/skills` symlinks, a Claude instruction link, package scripts for every validation and publishing command, and pnpm build-script policy when pnpm is selected. Non-interactive setup requires explicit ownership metadata: @@ -98,6 +98,8 @@ The plugin author writes two files: - `dist/manifest.json` — wire-shape manifest including the hooks + routes harvested from probing `src/plugin.ts`. - `dist/index.mjs` (+ `dist/index.d.mts`) — descriptor module that default-exports a bare `PluginDescriptor`. Consumers import this directly. +The generated `vitest.config.ts` builds the plugin with `@emdash-cms/plugin-test` and supplies D1, Worker Loader, and the production `PluginBridge` through `@cloudflare/vitest-plugin`. Generated tests invoke hooks and routes through the sandbox boundary instead of constructing a partial `PluginContext`. + ## Publishing The CLI builds the plugin and uploads the release artifacts to your PDS: diff --git a/packages/plugin-cli/src/init/scaffold.ts b/packages/plugin-cli/src/init/scaffold.ts index 3e34f0cd10..5eb493e285 100644 --- a/packages/plugin-cli/src/init/scaffold.ts +++ b/packages/plugin-cli/src/init/scaffold.ts @@ -37,6 +37,7 @@ import { renderReadme, renderTest, renderTsconfig, + renderVitestConfig, type ScaffoldInputs, } from "./templates.js"; @@ -92,6 +93,7 @@ const BASE_FILES = [ "README.md", "src/plugin.ts", "tests/plugin.test.ts", + "vitest.config.ts", "AGENTS.md", "skills/creating-plugins/SKILL.md", ] as const; @@ -269,6 +271,8 @@ function renderFile(file: ScaffoldFile, inputs: ScaffoldInputs): string { return renderPluginEntry(); case "tests/plugin.test.ts": return renderTest(inputs); + case "vitest.config.ts": + return renderVitestConfig(); case "AGENTS.md": return renderAgentsGuide(); case "skills/creating-plugins/SKILL.md": diff --git a/packages/plugin-cli/src/init/templates.ts b/packages/plugin-cli/src/init/templates.ts index 8e69bf01d9..ac31aef28f 100644 --- a/packages/plugin-cli/src/init/templates.ts +++ b/packages/plugin-cli/src/init/templates.ts @@ -15,6 +15,7 @@ * .gitignore * README.md * tests/plugin.test.ts + * vitest.config.ts * AGENTS.md * skills/creating-plugins/SKILL.md * .agents/skills -> ../skills @@ -256,6 +257,7 @@ export function renderPackageJson(input: ScaffoldInputs): string { }, devDependencies: { "@emdash-cms/plugin-cli": input.cliVersion, + "@emdash-cms/plugin-test": "^0.1.0", emdash: ">=0.12.0 <1.0.0", typescript: "^5.9.0", vitest: "^4.1.0", @@ -282,7 +284,7 @@ export function renderTsconfig(): string { skipLibCheck: true, types: [], }, - include: ["src/**/*", "tests/**/*"], + include: ["src/**/*", "tests/**/*", "vitest.config.ts"], exclude: ["node_modules"], }; return `${JSON.stringify(config, null, "\t")}\n`; @@ -329,6 +331,9 @@ directory (rebuilds on save) and \`${addLocal}\` in the site. Then \`import ${importBinding} from "${input.slug}"\` and pass it into \`emdash({ sandboxed: [${importBinding}] })\`. +\`${run("test")}\` builds the plugin and runs its tests in workerd through +EmDash's production sandbox wrapper and host bridge. + ## Publish \`\`\`sh @@ -355,41 +360,37 @@ behaviour slip past consent. } /** - * `tests/plugin.test.ts` — one passing test that exercises the - * hello route. Uses a minimal stubbed PluginContext rather than - * pulling in the runtime: the test asserts the handler returns the - * expected shape, not that the runtime wires it up correctly. + * `tests/plugin.test.ts` — one passing test through the production sandbox boundary. */ export function renderTest(input: ScaffoldInputs): string { - return `import { describe, expect, it } from "vitest"; + return `import { afterEach, describe, expect, it } from "vitest"; + +import { createPluginTestHost, type PluginTestHost } from "@emdash-cms/plugin-test"; + +let host: PluginTestHost | undefined; -import plugin from "../src/plugin.js"; +afterEach(async () => { +\tawait host?.dispose(); +\thost = undefined; +}); describe("hello route", () => { -\tit("returns a greeting", async () => { -\t\tconst handler = plugin.routes?.hello; -\t\tif (!handler || typeof handler !== "object" || !("handler" in handler)) { -\t\t\tthrow new Error("hello route handler not found"); -\t\t} -\t\tconst result = await handler.handler({} as never, makeTestContext()); +\tit("returns a greeting through the sandbox host", async () => { +\t\thost = await createPluginTestHost(); +\t\tconst result = await host.invokeRoute("hello"); \t\texpect(result).toEqual({ greeting: "hello", pluginId: ${JSON.stringify(input.slug)} }); \t}); }); - -function makeTestContext() { -\t// Minimal stub PluginContext: the hello route only reads -\t// \`ctx.log.info\` and \`ctx.plugin.id\`. Real PluginContext has many -\t// more methods; add them as your plugin grows. -\treturn { -\t\tplugin: { id: ${JSON.stringify(input.slug)}, version: "0.1.0" }, -\t\tlog: { -\t\t\tinfo: () => {}, -\t\t\twarn: () => {}, -\t\t\terror: () => {}, -\t\t\tdebug: () => {}, -\t\t}, -\t} as unknown as import("emdash").PluginContext; +`; } + +export function renderVitestConfig(): string { + return `import { emdashPluginTest } from "@emdash-cms/plugin-test/config"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ +\tplugins: [emdashPluginTest()], +}); `; } @@ -401,6 +402,7 @@ strictDepBuilds: true dangerouslyAllowAllBuilds: false allowBuilds: esbuild: true + workerd: true `; } @@ -433,7 +435,9 @@ Read \`emdash-plugin.jsonc\` and \`src/plugin.ts\` before editing. The manifest ## Validation -Use the package scripts in this repository. Before handing off a change, run validation, typecheck, tests, and build. A release also requires a version bump in \`package.json\` when runtime behavior or the trust contract changes. +Use the package scripts in this repository. The test script builds the plugin and runs it inside workerd through EmDash's production sandbox wrapper and host bridge. Use \`createPluginTestHost()\` to invoke hooks and routes, create content fixtures, and inspect plugin KV or declared storage. Dispose the host after each test so its bindings reset. + +Before handing off a change, run validation, typecheck, tests, and build. A release also requires a version bump in \`package.json\` when runtime behavior or the trust contract changes. ## Publishing diff --git a/packages/plugin-cli/tests/init-scaffold.test.ts b/packages/plugin-cli/tests/init-scaffold.test.ts index 9a04e27d72..5279020749 100644 --- a/packages/plugin-cli/tests/init-scaffold.test.ts +++ b/packages/plugin-cli/tests/init-scaffold.test.ts @@ -77,7 +77,7 @@ describe("scaffold", () => { it("writes the expected file tree", async () => { const result = await scaffold({ targetDir, inputs: FULL_INPUTS, force: false }); - expect(result.written).toHaveLength(12); + expect(result.written).toHaveLength(13); // Spot-check the structure rather than pinning the array order. const fileSet = new Set(result.written.map((p) => p.replace(`${targetDir}/`, ""))); @@ -88,6 +88,7 @@ describe("scaffold", () => { expect(fileSet.has("README.md")).toBe(true); expect(fileSet.has("src/plugin.ts")).toBe(true); expect(fileSet.has("tests/plugin.test.ts")).toBe(true); + expect(fileSet.has("vitest.config.ts")).toBe(true); expect(fileSet.has("AGENTS.md")).toBe(true); expect(fileSet.has("skills/creating-plugins/SKILL.md")).toBe(true); expect(fileSet.has(".agents/skills")).toBe(true); @@ -238,6 +239,7 @@ describe("scaffold", () => { "README.md", "src/plugin.ts", "tests/plugin.test.ts", + "vitest.config.ts", "AGENTS.md", "skills/creating-plugins/SKILL.md", ".agents/skills", diff --git a/packages/plugin-cli/tests/init-templates.test.ts b/packages/plugin-cli/tests/init-templates.test.ts index 6bf8d8b721..c31c9cba8f 100644 --- a/packages/plugin-cli/tests/init-templates.test.ts +++ b/packages/plugin-cli/tests/init-templates.test.ts @@ -21,6 +21,7 @@ import { renderReadme, renderTest, renderTsconfig, + renderVitestConfig, type ScaffoldInputs, } from "../src/init/templates.js"; import { ManifestSchema } from "../src/manifest/schema.js"; @@ -195,6 +196,7 @@ describe("renderPackageJson", () => { const parsed = JSON.parse(renderPackageJson(FULL_INPUTS)); expect(parsed.packageManager).toBe("npm@11.6.2"); expect(parsed.devDependencies["@emdash-cms/plugin-cli"]).toBe("0.10.0"); + expect(parsed.devDependencies["@emdash-cms/plugin-test"]).toBe("^0.1.0"); expect(parsed.devDependencies.emdash).toBe(">=0.12.0 <1.0.0"); }); @@ -227,6 +229,7 @@ describe("renderTsconfig", () => { const parsed = JSON.parse(renderTsconfig()); expect(parsed.include).toContain("src/**/*"); expect(parsed.include).toContain("tests/**/*"); + expect(parsed.include).toContain("vitest.config.ts"); }); }); @@ -252,20 +255,28 @@ describe("renderPluginEntry", () => { }); describe("renderTest", () => { - it("imports the plugin and exercises the hello route", () => { + it("exercises the hello route through the sandbox host", () => { const source = renderTest(FULL_INPUTS); - expect(source).toContain('from "../src/plugin.js"'); - expect(source).toContain("hello"); + expect(source).toContain('from "@emdash-cms/plugin-test"'); + expect(source).toContain('host.invokeRoute("hello")'); expect(source).toContain("expect(result)"); }); - it("uses the scaffolded plugin ID in the test context", () => { + it("expects the scaffolded plugin ID from the real host", () => { const source = renderTest(FULL_INPUTS); - expect(source).toContain('plugin: { id: "gallery"'); + expect(source).toContain('pluginId: "gallery"'); expect(source).not.toContain('id: "test-plugin"'); }); }); +describe("renderVitestConfig", () => { + it("configures the workerd-backed EmDash plugin host", () => { + const source = renderVitestConfig(); + expect(source).toContain('from "@emdash-cms/plugin-test/config"'); + expect(source).toContain("emdashPluginTest()"); + }); +}); + describe("renderGitignore", () => { it("ignores node_modules", () => { expect(renderGitignore()).toContain("node_modules"); @@ -318,9 +329,10 @@ describe("agent guidance", () => { expect(skill).toContain("Use the package scripts"); }); - it("allows the esbuild install script for pnpm projects", () => { + it("allows the build scripts required by sandbox tests in pnpm projects", () => { expect(renderPnpmWorkspace()).toContain("allowBuilds:"); expect(renderPnpmWorkspace()).toContain("esbuild: true"); + expect(renderPnpmWorkspace()).toContain("workerd: true"); }); }); diff --git a/packages/plugin-test/README.md b/packages/plugin-test/README.md new file mode 100644 index 0000000000..e5a8c9d0ee --- /dev/null +++ b/packages/plugin-test/README.md @@ -0,0 +1,25 @@ +# @emdash-cms/plugin-test + +Workerd-backed Vitest utilities for sandboxed EmDash plugins. + +```ts +// vitest.config.ts +import { emdashPluginTest } from "@emdash-cms/plugin-test/config"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [emdashPluginTest()], +}); +``` + +```ts +import { createPluginTestHost } from "@emdash-cms/plugin-test"; + +const host = await createPluginTestHost(); +await host.invokeRoute("health"); +await host.dispose(); +``` + +The configuration builds the plugin and supplies local D1 and Worker Loader bindings through `@cloudflare/vitest-plugin`. The host loads the built code through EmDash's production Cloudflare sandbox runner and `PluginBridge`. + +Read [Test sandboxed plugins](https://docs.emdashcms.com/plugins/creating-plugins/testing/) for hooks, content fixtures, storage assertions, and test boundaries. diff --git a/packages/plugin-test/package.json b/packages/plugin-test/package.json new file mode 100644 index 0000000000..7b93c130f2 --- /dev/null +++ b/packages/plugin-test/package.json @@ -0,0 +1,65 @@ +{ + "name": "@emdash-cms/plugin-test", + "version": "0.0.0", + "description": "Workerd-backed Vitest host for sandboxed EmDash plugins", + "type": "module", + "main": "dist/index.mjs", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./config": { + "types": "./dist/config.d.mts", + "default": "./dist/config.mjs" + }, + "./worker": { + "types": "./dist/worker.d.mts", + "default": "./dist/worker.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "test": "vitest run", + "typecheck": "tsgo --noEmit", + "check": "publint" + }, + "dependencies": { + "@cloudflare/vitest-plugin": "catalog:", + "@emdash-cms/cloudflare": "workspace:*", + "@emdash-cms/plugin-cli": "workspace:*", + "@emdash-cms/plugin-types": "workspace:*", + "emdash": "workspace:*", + "kysely": "catalog:" + }, + "peerDependencies": { + "vite": "^8.0.0", + "vitest": "^4.1.0" + }, + "devDependencies": { + "@arethetypeswrong/cli": "catalog:", + "@cloudflare/workers-types": "catalog:", + "publint": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/emdash-cms/emdash.git", + "directory": "packages/plugin-test" + }, + "homepage": "https://github.com/emdash-cms/emdash", + "keywords": [ + "emdash", + "plugin", + "vitest", + "workerd" + ], + "author": "Matt Kane", + "license": "MIT" +} diff --git a/packages/plugin-test/src/config.ts b/packages/plugin-test/src/config.ts new file mode 100644 index 0000000000..79a00e62af --- /dev/null +++ b/packages/plugin-test/src/config.ts @@ -0,0 +1,46 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { cloudflareTest } from "@cloudflare/vitest-plugin"; +import { buildPlugin } from "@emdash-cms/plugin-cli"; +import type { Plugin } from "vite"; + +export interface EmDashPluginTestOptions { + /** Plugin source directory. Defaults to the current working directory. */ + dir?: string; +} + +/** + * Configure Vitest to build a sandboxed plugin and run tests inside workerd. + */ +export function emdashPluginTest(options: EmDashPluginTestOptions = {}): Plugin { + const pluginDir = resolve(options.dir ?? process.cwd()); + const workerEntry = fileURLToPath( + new URL(import.meta.url.endsWith(".ts") ? "./worker.ts" : "./worker.mjs", import.meta.url), + ); + + return cloudflareTest(async () => { + const build = await buildPlugin({ dir: pluginDir }); + const [code, manifest] = await Promise.all([ + readFile(build.files.runtime, "utf8"), + readFile(build.files.manifestJson, "utf8"), + ]); + + return { + main: workerEntry, + remoteBindings: false, + additionalExports: { PluginBridge: "WorkerEntrypoint" }, + miniflare: { + compatibilityDate: "2026-08-20", + compatibilityFlags: ["nodejs_compat"], + d1Databases: ["DB"], + workerLoaders: { LOADER: {} }, + bindings: { + EMDASH_PLUGIN_CODE: code, + EMDASH_PLUGIN_MANIFEST: manifest, + }, + }, + }; + }); +} diff --git a/packages/plugin-test/src/index.ts b/packages/plugin-test/src/index.ts new file mode 100644 index 0000000000..2518d4615f --- /dev/null +++ b/packages/plugin-test/src/index.ts @@ -0,0 +1,198 @@ +import { createDialect } from "@emdash-cms/cloudflare/db/d1"; +import { CloudflareSandboxRunner } from "@emdash-cms/cloudflare/sandbox"; +import { pluginManifestSchema } from "@emdash-cms/plugin-types"; +import { reset } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { + ContentRepository, + SchemaRegistry, + type CreateCollectionInput, + type CreateFieldInput, + type Database, + type PluginManifest, +} from "emdash"; +import { runMigrations } from "emdash/db"; +import { Kysely } from "kysely"; + +interface PluginTestBindings { + DB: D1Database; + EMDASH_PLUGIN_CODE: string; + EMDASH_PLUGIN_MANIFEST: string; +} + +export interface PluginTestRequest { + url?: string; + method?: string; + headers?: Record; + meta?: { + ip: string | null; + userAgent: string | null; + referer: string | null; + geo: { country: string | null; region: string | null; city: string | null } | null; + }; + user?: { + id: string; + email: string; + name: string | null; + role: number; + createdAt: string; + }; +} + +export interface PluginTestCollection extends CreateCollectionInput { + fields?: CreateFieldInput[]; +} + +export interface PluginStorageTestEntry { + id: string; + data: T; +} + +export interface PluginTestHost { + readonly manifest: PluginManifest; + invokeHook(name: string, event: unknown): Promise; + invokeRoute(name: string, input?: unknown, request?: PluginTestRequest): Promise; + createCollection(input: PluginTestCollection): Promise; + seedContent(collection: string, items: Array>): Promise; + storage( + collection: string, + ): { + get(id: string): Promise; + list(): Promise>>; + }; + kv: { + get(key: string): Promise; + list(): Promise>; + }; + dispose(): Promise; +} + +const DEFAULT_META = { + ip: null, + userAgent: null, + referer: null, + geo: null, +} as const; + +function isPluginTestBindings(value: unknown): value is PluginTestBindings { + if (typeof value !== "object" || value === null) return false; + if (!("DB" in value) || typeof value.DB !== "object" || value.DB === null) return false; + return ( + "prepare" in value.DB && + typeof value.DB.prepare === "function" && + "EMDASH_PLUGIN_CODE" in value && + typeof value.EMDASH_PLUGIN_CODE === "string" && + "EMDASH_PLUGIN_MANIFEST" in value && + typeof value.EMDASH_PLUGIN_MANIFEST === "string" + ); +} + +/** + * Load the configured plugin through EmDash's production Cloudflare sandbox runner. + */ +export async function createPluginTestHost(): Promise { + const bindings: unknown = env; + if (!isPluginTestBindings(bindings)) { + throw new Error( + "EmDash plugin test bindings are unavailable; add emdashPluginTest() to Vitest", + ); + } + const manifestResult = pluginManifestSchema.safeParse( + JSON.parse(bindings.EMDASH_PLUGIN_MANIFEST), + ); + if (!manifestResult.success) throw new Error("EmDash plugin test manifest is invalid"); + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- the shared runtime schema validates the wire manifest before it enters core's equivalent runtime type + const manifest = manifestResult.data as unknown as PluginManifest; + const db = new Kysely({ + dialect: createDialect({ binding: "DB", session: "disabled" }), + }); + await runMigrations(db); + + const runner = new CloudflareSandboxRunner({ + db, + siteInfo: { + name: "EmDash plugin test site", + url: "https://plugin.test", + locale: "en", + }, + }); + if (!runner.isAvailable()) { + await db.destroy(); + throw new Error(`Plugin sandbox unavailable: ${runner.unavailableReason()}`); + } + const plugin = await runner.load(manifest, bindings.EMDASH_PLUGIN_CODE); + const registry = new SchemaRegistry(db); + const content = new ContentRepository(db); + let disposed = false; + + const assertActive = () => { + if (disposed) throw new Error("Plugin test host has been disposed"); + }; + const readStorage = async (collection: string, id?: string) => { + assertActive(); + let query = bindings.DB.prepare( + "SELECT id, data FROM _plugin_storage WHERE plugin_id = ? AND collection = ?", + ).bind(manifest.id, collection); + if (id !== undefined) { + query = bindings.DB.prepare( + "SELECT id, data FROM _plugin_storage WHERE plugin_id = ? AND collection = ? AND id = ?", + ).bind(manifest.id, collection, id); + } + const result = await query.all<{ id: string; data: string }>(); + return (result.results ?? []).map((row) => ({ + id: row.id, + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- the caller supplies the expected stored JSON type + data: JSON.parse(row.data) as T, + })); + }; + + return { + manifest, + async invokeHook(name, event) { + assertActive(); + return plugin.invokeHook(name, event); + }, + async invokeRoute(name, input = {}, request = {}) { + assertActive(); + return plugin.invokeRoute(name, input, { + url: request.url ?? `https://plugin.test/_emdash/api/plugins/${manifest.id}/${name}`, + method: request.method ?? "POST", + headers: request.headers ?? {}, + meta: request.meta ?? DEFAULT_META, + user: request.user, + }); + }, + async createCollection({ fields = [], ...collection }) { + assertActive(); + await registry.createCollection(collection); + for (const field of fields) await registry.createField(collection.slug, field); + }, + async seedContent(collection, items) { + assertActive(); + for (const data of items) await content.create({ type: collection, data }); + }, + storage(collection: string) { + return { + async get(id: string) { + const rows = await readStorage(collection, id); + return rows[0]?.data ?? null; + }, + list: () => readStorage(collection), + }; + }, + kv: { + async get(key: string) { + const rows = await readStorage("__kv", key); + return rows[0]?.data ?? null; + }, + list: () => readStorage("__kv"), + }, + async dispose() { + if (disposed) return; + disposed = true; + await runner.terminateAll(); + await db.destroy(); + await reset(); + }, + }; +} diff --git a/packages/plugin-test/src/worker.ts b/packages/plugin-test/src/worker.ts new file mode 100644 index 0000000000..1a96cfeced --- /dev/null +++ b/packages/plugin-test/src/worker.ts @@ -0,0 +1,7 @@ +export { PluginBridge } from "@emdash-cms/cloudflare/sandbox"; + +export default { + fetch() { + return new Response("EmDash plugin test host"); + }, +} satisfies ExportedHandler; diff --git a/packages/plugin-test/test/fixture/emdash-plugin.jsonc b/packages/plugin-test/test/fixture/emdash-plugin.jsonc new file mode 100644 index 0000000000..2d0478ce58 --- /dev/null +++ b/packages/plugin-test/test/fixture/emdash-plugin.jsonc @@ -0,0 +1,12 @@ +{ + "slug": "plugin-test-fixture", + "publisher": "did:plc:xyraubanwc5fwemkduw3upi6", + "license": "MIT", + "author": { "name": "EmDash" }, + "security": { "url": "https://github.com/emdash-cms/emdash/security/advisories/new" }, + "capabilities": ["content:read"], + "allowedHosts": [], + "storage": { + "events": { "indexes": ["type"] }, + }, +} diff --git a/packages/plugin-test/test/fixture/package.json b/packages/plugin-test/test/fixture/package.json new file mode 100644 index 0000000000..b5b21ec03f --- /dev/null +++ b/packages/plugin-test/test/fixture/package.json @@ -0,0 +1,8 @@ +{ + "name": "plugin-test-fixture", + "version": "0.1.0", + "type": "module", + "peerDependencies": { + "emdash": "workspace:*" + } +} diff --git a/packages/plugin-test/test/fixture/src/plugin.ts b/packages/plugin-test/test/fixture/src/plugin.ts new file mode 100644 index 0000000000..88500bbd1b --- /dev/null +++ b/packages/plugin-test/test/fixture/src/plugin.ts @@ -0,0 +1,30 @@ +import type { SandboxedPlugin } from "emdash/plugin"; + +const plugin: SandboxedPlugin = { + hooks: { + "content:afterSave": { + handler: async (event, ctx) => { + await ctx.storage.events!.put(String(event.content.id), { + type: "saved", + collection: event.collection, + }); + }, + }, + }, + routes: { + hello: { + handler: async (_route, ctx) => { + await ctx.kv.set("last-route", "hello"); + return { pluginId: ctx.plugin.id }; + }, + }, + "content-count": { + handler: async (_route, ctx) => { + const result = await ctx.content!.list("posts"); + return { count: result.items.length }; + }, + }, + }, +}; + +export default plugin; diff --git a/packages/plugin-test/test/host.test.ts b/packages/plugin-test/test/host.test.ts new file mode 100644 index 0000000000..8c7d4ab3b5 --- /dev/null +++ b/packages/plugin-test/test/host.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { createPluginTestHost, type PluginTestHost } from "../src/index.js"; + +let host: PluginTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; +}); + +describe("plugin test host", () => { + it("loads the built plugin through Worker Loader and persists host state", async () => { + host = await createPluginTestHost(); + + await expect(host.invokeRoute("hello")).resolves.toEqual({ + pluginId: "plugin-test-fixture", + }); + await expect(host.kv.get("last-route")).resolves.toBe("hello"); + + await host.invokeHook("content:afterSave", { + collection: "posts", + content: { id: "post-1" }, + }); + await expect(host.storage("events").get("post-1")).resolves.toEqual({ + type: "saved", + collection: "posts", + }); + }); + + it("uses a migrated D1 database for content bridge calls", async () => { + 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("content-count")).resolves.toEqual({ count: 2 }); + }); +}); diff --git a/packages/plugin-test/tsconfig.json b/packages/plugin-test/tsconfig.json new file mode 100644 index 0000000000..3880306dde --- /dev/null +++ b/packages/plugin-test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types", "@cloudflare/vitest-plugin/types", "node"], + "noEmit": true + }, + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] +} diff --git a/packages/plugin-test/tsdown.config.ts b/packages/plugin-test/tsdown.config.ts new file mode 100644 index 0000000000..8f1b394783 --- /dev/null +++ b/packages/plugin-test/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts", "src/config.ts", "src/worker.ts"], + format: "esm", + dts: true, + clean: true, + external: ["cloudflare:workers", "cloudflare:test"], +}); diff --git a/packages/plugin-test/vitest.config.ts b/packages/plugin-test/vitest.config.ts new file mode 100644 index 0000000000..ff4ad44c4f --- /dev/null +++ b/packages/plugin-test/vitest.config.ts @@ -0,0 +1,16 @@ +import { fileURLToPath } from "node:url"; + +import { defineConfig } from "vitest/config"; + +import { emdashPluginTest } from "./src/config.js"; + +const fixture = fileURLToPath(new URL("./test/fixture", import.meta.url)); + +export default defineConfig({ + plugins: [emdashPluginTest({ dir: fixture })], + test: { + include: ["test/**/*.test.ts"], + testTimeout: 30_000, + hookTimeout: 30_000, + }, +}); diff --git a/packages/registry-verification/package.json b/packages/registry-verification/package.json index 276c71d350..624cbf5cae 100644 --- a/packages/registry-verification/package.json +++ b/packages/registry-verification/package.json @@ -45,7 +45,7 @@ }, "devDependencies": { "@arethetypeswrong/cli": "catalog:", - "@cloudflare/vitest-pool-workers": "catalog:", + "@cloudflare/vitest-plugin": "catalog:", "@sigstore/bundle": "5.0.0", "@sigstore/core": "4.0.1", "@sigstore/protobuf-specs": "0.5.1", diff --git a/packages/registry-verification/tsconfig.json b/packages/registry-verification/tsconfig.json index 8c7c2bda9a..5703e92f91 100644 --- a/packages/registry-verification/tsconfig.json +++ b/packages/registry-verification/tsconfig.json @@ -4,7 +4,7 @@ "outDir": "./dist", "rootDir": "./src", "lib": ["es2024", "dom", "esnext.typedarrays"], - "types": ["@cloudflare/vitest-pool-workers/types"] + "types": ["@cloudflare/vitest-plugin/types"] }, "include": ["src/**/*", "tests/**/*"], "exclude": ["node_modules", "dist", "tests"] diff --git a/packages/registry-verification/vitest.workerd.config.ts b/packages/registry-verification/vitest.workerd.config.ts index 859f135ba2..ba4d2abad9 100644 --- a/packages/registry-verification/vitest.workerd.config.ts +++ b/packages/registry-verification/vitest.workerd.config.ts @@ -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({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0047eb3a50..f88e04ee25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,9 +90,6 @@ catalogs: '@cloudflare/vitest-plugin': specifier: ^1.0.0 version: 1.0.0 - '@cloudflare/vitest-pool-workers': - specifier: ^0.16.3 - version: 0.16.3 '@cloudflare/workers-types': specifier: ^5.20260820.1 version: 5.20260906.1 @@ -433,9 +430,9 @@ importers: '@cloudflare/vite-plugin': specifier: 'catalog:' version: 1.36.3(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))(workerd@1.20260815.1)(wrangler@4.124.0) - '@cloudflare/vitest-pool-workers': + '@cloudflare/vitest-plugin': specifier: 'catalog:' - version: 0.16.3(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) + version: 1.0.0(@cloudflare/workers-types@5.20260906.1)(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) '@emdash-cms/atproto-test-utils': specifier: workspace:* version: link:../../packages/atproto-test-utils @@ -661,9 +658,9 @@ importers: '@cloudflare/vite-plugin': specifier: 'catalog:' version: 1.36.3(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))(workerd@1.20260820.1)(wrangler@4.124.0) - '@cloudflare/vitest-pool-workers': + '@cloudflare/vitest-plugin': specifier: 'catalog:' - version: 0.16.3(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) + version: 1.0.0(@cloudflare/workers-types@5.20260906.1)(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) '@playwright/test': specifier: ^1.61.1 version: 1.61.1 @@ -716,9 +713,9 @@ importers: '@cloudflare/vite-plugin': specifier: 'catalog:' version: 1.36.3(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))(workerd@1.20260820.1)(wrangler@4.124.0) - '@cloudflare/vitest-pool-workers': + '@cloudflare/vitest-plugin': specifier: 'catalog:' - version: 0.16.3(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) + version: 1.0.0(@cloudflare/workers-types@5.20260906.1)(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) '@types/node': specifier: 'catalog:' version: 24.10.13 @@ -1350,9 +1347,9 @@ importers: '@cloudflare/vite-plugin': specifier: 1.53.0 version: 1.53.0(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.124.0) - '@cloudflare/vitest-pool-workers': + '@cloudflare/vitest-plugin': specifier: 'catalog:' - version: 0.16.3(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) + version: 1.0.0(@cloudflare/workers-types@5.20260906.1)(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) '@flue/vite': specifier: 2.0.3 version: 2.0.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/codemode@0.4.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.5.4))(ai@6.0.172(zod@4.5.4))(zod@4.5.4))(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.5.4))(@modelcontextprotocol/server@2.0.0)(@x402/core@2.8.0)(@x402/evm@2.8.0(typescript@6.0.3))(ai@6.0.172(zod@4.5.4))(hono@4.12.27)(just-bash@3.0.1)(react@19.2.4)(rolldown@1.0.3)(typescript@6.0.3)(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))(ws@8.21.3)(zod@4.5.4) @@ -2164,9 +2161,9 @@ importers: '@arethetypeswrong/cli': specifier: 'catalog:' version: 0.18.2 - '@cloudflare/vitest-pool-workers': + '@cloudflare/vitest-plugin': specifier: 'catalog:' - version: 0.16.3(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5) + version: 1.0.0(@cloudflare/workers-types@5.20260906.1)(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5) '@emdash-cms/atproto-test-utils': specifier: workspace:* version: link:../atproto-test-utils @@ -2377,6 +2374,49 @@ importers: specifier: 'catalog:' version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + packages/plugin-test: + dependencies: + '@cloudflare/vitest-plugin': + specifier: 'catalog:' + version: 1.0.0(@cloudflare/workers-types@5.20260906.1)(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) + '@emdash-cms/cloudflare': + specifier: workspace:* + version: link:../cloudflare + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../plugin-cli + '@emdash-cms/plugin-types': + specifier: workspace:* + version: link:../plugin-types + emdash: + specifier: workspace:* + version: link:../core + kysely: + specifier: 'catalog:' + version: 0.29.2 + devDependencies: + '@arethetypeswrong/cli': + specifier: 'catalog:' + version: 0.18.2 + '@cloudflare/workers-types': + specifier: 'catalog:' + version: 5.20260906.1 + publint: + specifier: 'catalog:' + version: 0.3.17 + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3))(publint@0.3.17)(typescript@6.0.3) + typescript: + specifier: 'catalog:' + version: 6.0.3 + vite: + specifier: 'catalog:' + version: 8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + packages/plugin-types: dependencies: zod: @@ -2759,9 +2799,9 @@ importers: '@arethetypeswrong/cli': specifier: 'catalog:' version: 0.18.2 - '@cloudflare/vitest-pool-workers': + '@cloudflare/vitest-plugin': specifier: 'catalog:' - version: 0.16.3(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) + version: 1.0.0(@cloudflare/workers-types@5.20260906.1)(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) '@sigstore/bundle': specifier: 5.0.0 version: 5.0.0 @@ -4307,13 +4347,6 @@ packages: '@vitest/snapshot': ^4.1.0 vitest: ^4.1.0 - '@cloudflare/vitest-pool-workers@0.16.3': - resolution: {integrity: sha512-cnxtKBWoP5uhO78Z9zlCexj7oIs6zYoIH4GCEFS0Z6bepFDdnxtuMGxmWaDg84cy9teoh5CLA/OEN6XLSKcnWA==} - peerDependencies: - '@vitest/runner': ^4.1.0 - '@vitest/snapshot': ^4.1.0 - vitest: ^4.1.0 - '@cloudflare/workerd-darwin-64@1.20260301.1': resolution: {integrity: sha512-+kJvwociLrvy1JV9BAvoSVsMEIYD982CpFmo/yMEvBwxDIjltYsLTE8DLi0mCkGsQ8Ygidv2fD9wavzXeiY7OQ==} engines: {node: '>=16'} @@ -15552,61 +15585,46 @@ snapshots: - bufferutil - utf-8-validate - '@cloudflare/vitest-pool-workers@0.16.3(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)))': + '@cloudflare/vitest-plugin@1.0.0(@cloudflare/workers-types@5.20260906.1)(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)))': dependencies: '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 - cjs-module-lexer: 1.4.0 - esbuild: 0.27.3 - miniflare: 4.20260507.1 - vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) - wrangler: 4.90.0 - zod: 3.25.76 - transitivePeerDependencies: - - '@cloudflare/workers-types' - - bufferutil - - utf-8-validate - - '@cloudflare/vitest-pool-workers@0.16.3(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)))': - dependencies: - '@vitest/runner': 4.1.5 - '@vitest/snapshot': 4.1.5 - cjs-module-lexer: 1.4.0 - esbuild: 0.27.3 - miniflare: 4.20260507.1 + cjs-module-lexer: 1.2.3 + esbuild: 0.28.1 + miniflare: 5.20260820.0-alpha vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) - wrangler: 4.90.0 - zod: 3.25.76 + wrangler: 4.125.0(@cloudflare/workers-types@5.20260906.1) + zod: 4.4.3 transitivePeerDependencies: - '@cloudflare/workers-types' - bufferutil - utf-8-validate - '@cloudflare/vitest-pool-workers@0.16.3(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)))': + '@cloudflare/vitest-plugin@1.0.0(@cloudflare/workers-types@5.20260906.1)(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)))': dependencies: '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 - cjs-module-lexer: 1.4.0 - esbuild: 0.27.3 - miniflare: 4.20260507.1 + cjs-module-lexer: 1.2.3 + esbuild: 0.28.1 + miniflare: 5.20260820.0-alpha vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) - wrangler: 4.90.0 - zod: 3.25.76 + wrangler: 4.125.0(@cloudflare/workers-types@5.20260906.1) + zod: 4.4.3 transitivePeerDependencies: - '@cloudflare/workers-types' - bufferutil - utf-8-validate - '@cloudflare/vitest-pool-workers@0.16.3(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5)': + '@cloudflare/vitest-plugin@1.0.0(@cloudflare/workers-types@5.20260906.1)(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5)': dependencies: '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 - cjs-module-lexer: 1.4.0 - esbuild: 0.27.3 - miniflare: 4.20260507.1 + cjs-module-lexer: 1.2.3 + esbuild: 0.28.1 + miniflare: 5.20260820.0-alpha vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(@vitest/ui@4.1.10)(jsdom@26.1.0)(vite@6.4.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) - wrangler: 4.90.0 - zod: 3.25.76 + wrangler: 4.125.0(@cloudflare/workers-types@5.20260906.1) + zod: 4.4.3 transitivePeerDependencies: - '@cloudflare/workers-types' - bufferutil diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9ba1729320..a859f3100c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -103,7 +103,6 @@ catalog: "@cloudflare/kumo": 2.6.0 "@cloudflare/vite-plugin": ^1.36.3 "@cloudflare/vitest-plugin": ^1.0.0 - "@cloudflare/vitest-pool-workers": ^0.16.3 "@cloudflare/workers-types": ^5.20260820.1 "@iconify-json/ph": ^1.2.2 "@lingui/babel-plugin-lingui-macro": ^5.9.4 diff --git a/skills/creating-plugins/SKILL.md b/skills/creating-plugins/SKILL.md index 7298725ea3..6342fe9388 100644 --- a/skills/creating-plugins/SKILL.md +++ b/skills/creating-plugins/SKILL.md @@ -11,10 +11,10 @@ EmDash plugins extend the CMS with hooks, storage, settings, admin UI, API route EmDash has two plugin formats: -| Type | Format | Admin UI | Where it runs | -| ------------ | ------------------------------------------------------- | ------------------ | ------------------------------------------- | -| **Standard** | `definePlugin({ hooks, routes })` | Block Kit | Isolate on Cloudflare, in-process elsewhere | -| **Native** | `createPlugin()` / `definePlugin()` with `id`+`version` | React or Block Kit | Always in host isolate | +| Type | Format | Admin UI | Where it runs | +| ------------ | ------------------------------------------------------- | ------------------ | --------------------------------------------- | +| **Standard** | `definePlugin({ hooks, routes })` | Block Kit | Isolated by a configured runner or in-process | +| **Native** | `createPlugin()` / `definePlugin()` with `id`+`version` | React or Block Kit | Always in host isolate | **Standard is the default.** Most plugins should use it. Standard plugins can be published to the marketplace and work in both trusted and sandboxed modes. @@ -122,28 +122,28 @@ Standard plugins work in either array. Native plugins only work in `plugins: []` EmDash has two execution modes. Plugin code is identical in both — only the enforcement changes. -| | Trusted | Sandboxed | -| ------------------- | ----------------------------------------- | ------------------------------------------------------ | -| **Runs in** | Main process | Isolated V8 isolate (Dynamic Worker Loader) | -| **Install method** | `astro.config.mjs` (code change + deploy) | Admin UI (one-click from marketplace) | -| **Capabilities** | Advisory (not enforced) | Enforced at runtime via RPC bridge | -| **Resource limits** | None | CPU 50ms, 10 subrequests, 30s wall-time, ~128MB memory | -| **Network access** | Unrestricted | Blocked; only via `ctx.http` with `allowedHosts` | -| **Data access** | Full database access | Scoped to declared capabilities | -| **Node.js APIs** | Full access | Not available (V8 isolate only) | -| **Available on** | All platforms | Cloudflare Workers only | -| **Best for** | First-party code, reviewed npm packages | Third-party extensions, marketplace plugins | +| | Trusted | Sandboxed | +| ------------------- | ------------------------------------------------------ | ------------------------------------------------------ | +| **Runs in** | Main process | Isolated V8 runtime supplied by the configured runner | +| **Install method** | `astro.config.mjs` (code change + deploy) | Admin UI (one-click from marketplace) | +| **Capabilities** | Gated through `PluginContext`; not a security boundary | Enforced at runtime via RPC bridge | +| **Resource limits** | None | Platform limits on Cloudflare; wall time on Node.js | +| **Network access** | Unrestricted | Blocked; only via `ctx.http` with `allowedHosts` | +| **Data access** | Full database access | Scoped to declared capabilities | +| **Node.js APIs** | Full access | Not available (V8 isolate only) | +| **Available on** | All platforms | Cloudflare Workers and Node.js with the workerd runner | +| **Best for** | First-party code, reviewed npm packages | Third-party extensions, marketplace plugins | ### Trusted Mode Trusted plugins are npm packages or local files added in `astro.config.mjs`. They run in-process with your Astro site. -- **Capabilities are documentation only.** Declaring `["content:read"]` documents intent but isn't enforced — the plugin has full process access. +- **`PluginContext` gates capabilities.** Declaring `["content:read"]` exposes only the corresponding context methods, but native code can bypass the context and use process APIs directly. - Only install from sources you trust. A malicious trusted plugin has the same access as your application code. ### Sandboxed Mode -Sandboxed plugins run in isolated V8 isolates on Cloudflare Workers via [Dynamic Worker Loader](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/). Each plugin gets its own isolate. +Sandboxed plugins run in isolated V8 runtimes through the configured platform runner. Cloudflare Workers uses [Dynamic Worker Loader](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/); Node.js uses the workerd runner. Each plugin gets its own isolate. - **Capabilities are enforced.** If a plugin declares `["content:read"]`, it can only call `ctx.content.get()` and `ctx.content.list()`. Attempting `ctx.content.create()` throws a permission error. - **Network is blocked by default.** Direct `fetch()` calls fail. Plugins must use `ctx.http.fetch()`, which validates against `allowedHosts`. @@ -152,11 +152,11 @@ Sandboxed plugins run in isolated V8 isolates on Cloudflare Workers via [Dynamic - **No Portable Text block types.** PT blocks require Astro components for site-side rendering (`componentsEntry`), which are loaded at build time from npm. Sandboxed plugins are installed at runtime and can't ship components. PT blocks are a native-plugin-only feature. - **Routes work.** Standard plugin routes are available in both trusted and sandboxed modes via the sandbox runner's `invokeRoute()` RPC. -Sandboxing is not available on Node.js. All plugins run in trusted mode on non-Cloudflare platforms. +On Cloudflare Workers, the sandbox runner uses Dynamic Workers through Worker Loader. On Node.js, `@emdash-cms/sandbox-workerd` runs plugins in workerd. Both paths isolate plugin code and enforce the host bridge; only Cloudflare enforces CPU and subrequest limits. ### Developing for Both Modes -Write the same code. Develop locally in trusted mode (faster iteration, easier debugging). Deploy to sandboxed mode in production without code changes. With the standard format, the same entrypoint serves both modes -- no separate sandbox entry needed. +Write the same code for trusted and sandboxed execution. Use `@emdash-cms/plugin-test` during development so Vitest builds the plugin and invokes it through Worker Loader, the production sandbox wrapper, and `PluginBridge`. Use an in-process site only when diagnosing whether a failure comes from plugin logic or the sandbox runtime. ```typescript // src/sandbox-entry.ts -- works in both trusted and sandboxed modes @@ -182,6 +182,32 @@ export default definePlugin({ Key constraint for sandbox compatibility: **no Node.js built-ins** (`fs`, `path`, `child_process`, etc.) in backend code. Use Web APIs instead. +## Testing sandboxed plugins + +Add the EmDash test plugin to `vitest.config.ts`: + +```typescript +import { emdashPluginTest } from "@emdash-cms/plugin-test/config"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [emdashPluginTest()], +}); +``` + +Create a fresh host in each test and dispose it afterward: + +```typescript +import { createPluginTestHost } from "@emdash-cms/plugin-test"; + +const host = await createPluginTestHost(); +await host.invokeHook("content:afterSave", event); +await host.invokeRoute("health"); +await host.dispose(); +``` + +The host runs the built plugin in a separate workerd isolate. Use `createCollection()` and `seedContent()` for content fixtures, `storage()` for declared plugin storage, and `kv` for key-value assertions. Capability and allowed-host failures cross the same RPC boundary as production. These tests do not render the admin application or reproduce Cloudflare's deployed CPU, memory, and subrequest limits. + ## Capabilities Capabilities control what APIs are available on `ctx`. Always declare what your plugin needs — even in trusted mode, they document intent and are required for sandboxed execution.