@emdash-cms/plugin-test 构建沙箱插件,并通过本地 Worker Loader 绑定运行其测试。测试使用 EmDash 的生产 Cloudflare 沙箱包装器和 PluginBridge,以及由 @cloudflare/vitest-plugin 提供的本地 D1 与 Worker Loader 绑定。
选择与被测行为匹配的主机:
createPluginTestHost()通过沙箱传输直接调用 hook 或路由。用于序列化、capability 强制执行、插件存储与路由处理程序逻辑。createPluginRuntimeTestHost()运行真实的 EmDash 内容、插件激活、媒体、评论、计划任务与插件路由操作。当测试必须证明主机操作到达插件时使用。
由 emdash-plugin init 创建的项目包含此设置。现有插件项目可将测试主机安装为开发依赖:
pnpm add -D @emdash-cms/plugin-test vitest
若项目限制依赖构建脚本,请允许 workerd 安装其平台二进制。生成的 pnpm 策略包含此条目:
allowBuilds:
workerd: true
配置 Vitest
将 EmDash 测试插件添加到项目的 Vitest 配置:
import { emdashPluginTest } from "@emdash-cms/plugin-test/config";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [emdashPluginTest()],
});
emdashPluginTest() 在 Vitest 启动前运行插件构建。它读取生成的运行时和清单,创建隔离的 D1 数据库与 Worker Loader 绑定,并导出 Cloudflare 部署使用的同一 PluginBridge。当 Vitest 配置位于插件目录之外时,传入 { dir: "./packages/gallery" }。
测试沙箱传输
在每个测试中创建并处置主机。处置会停止插件并重置其测试绑定:
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() 接受输入值和可选请求属性。默认请求是带有空标头和请求元数据的、发往插件路由的 POST。
直接调用不会演练 EmDash 路由身份验证、权限、令牌范围、跨站请求伪造(CSRF)或缓存策略。对这些检查使用运行时主机上的 host.actions.routes.request()。
测试 hooks 与存储
使用它们从 EmDash 接收的事件形状调用 hooks。存储和 KV 读取器检查通过桥写入的状态:
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",
});
存储调用仍强制执行 emdash-plugin.jsonc 中声明的集合。内容、媒体、用户、邮件与网络调用仍强制执行插件声明的 capabilities 与允许的主机。
植入内容
在调用读取站点内容的路由或 hook 之前,创建集合并植入条目:
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 });
集合与条目使用针对 D1 的真实 EmDash schema 注册表和内容仓库。
测试主机操作
当结果依赖 EmDash 编排时,创建运行时主机。fixtures 在不触发插件 hooks 的情况下写入初始状态。actions 调用生产运行时或处理程序边界,inspectors 在不调用插件代码的情况下读取可观察状态。
对于其 content:beforeSave hook 向标题追加 [checked] 的插件,以下测试证明内容保存到达该 hook:
import { afterEach, describe, expect, it } from "vitest";
import {
createPluginRuntimeTestHost,
type PluginRuntimeTestHost,
} from "@emdash-cms/plugin-test";
let host: PluginRuntimeTestHost | undefined;
afterEach(async () => {
await host?.dispose();
host = undefined;
});
describe("content save", () => {
it("applies the plugin hook", async () => {
host = await createPluginRuntimeTestHost();
await host.fixtures.collection({
slug: "posts",
label: "Posts",
fields: [{ slug: "title", label: "Title", type: "string" }],
});
const result = await host.actions.content.create("posts", {
data: { title: "First post" },
});
expect(result).toMatchObject({
success: true,
data: { item: { data: { title: "First post [checked]" } } },
});
});
});
运行时主机按边界分组其 API:
transport直接调用隔离以进行传输级检查。admin加载已验证的 Block Kit 页面与小部件,提交表单,并通过带有主机证明语言环境上下文的生产路由边界调用操作。fixtures在不触发 hooks 的情况下创建站点、集合、字段、用户、署名、分类法、内容、重定向、二进制媒体与插件状态。actions运行内容状态更改、插件激活与停用、生成的设置更新、媒体上传、公开评论提交、评论审核以及经策略检查的插件路由。inspect读取内容、重定向、署名署名、分类法分配、插件存储、KV、原始持久化设置信封、插件状态、计划任务、发布策略拒绝、媒体元数据与字节、评论以及捕获的邮件。使用inspect.scheduledPolicyRejections()验证调度器否决已持久化以供管理员关注。scheduled控制 cron 任务与计划发布的有效时间,然后运行一个生产维护批次。http排队外部响应,并捕获插件通过ctx.http.fetch()发送的请求。restart()在保留 D1、插件存储、媒体存储与插件状态的同时替换运行时与隔离。
每个测试后调用 dispose()。处置会终止隔离并重置所有绑定,因此后续测试无法观察前一主机的数据库或媒体。
使用设置操作与原始检查器,证明生成的设置保存到达生产处理程序且不持久化明文:
const result = await host.actions.plugin.updateSettings({ apiKey: "test-secret" });
expect(result).toMatchObject({ success: true, data: { secretsSet: { apiKey: true } } });
const stored = await host.inspect.settings.raw("apiKey");
expect(stored).toMatchObject({ v: 1, kid: expect.any(String) });
expect(JSON.stringify(stored)).not.toContain("test-secret");
在创建主机之前,在测试进程中设置 EMDASH_ENCRYPTION_KEY。原始检查器故意返回持久化的信封;使用插件的路由或 hook 验证解密后的 ctx.settings 值。
使用 host.fixtures.redirect() 在不调用插件的情况下建立重定向状态。在插件调用 ctx.redirects 之后,使用 host.inspect.redirects() 断言持久化规则。
使用二进制 fixture 通过运行时的存储适配器与 Worker Loader 桥测试 media:bytes:read。以下 fixture 故意报告较小的数据库大小,以便测试证明流限制具有权威性:
const fixture = await host.fixtures.media({
filename: "sample.bin",
mimeType: "application/octet-stream",
bytes: new Uint8Array([0, 255, 17, 42]),
reportedSize: 1,
contentHash: "sha1:sample",
});
await expect(host.inspect.mediaBytes(fixture.id)).resolves.toEqual(
new Uint8Array([0, 255, 17, 42]),
);
对创建翻译使用 createPluginRuntimeTestHost()。直接传输主机不运行复制共享字段与归属的运行时拥有的翻译生命周期。
在调用获取它的插件路由之前排队二进制响应:
const admin = await host.fixtures.user({
email: "plugin-test@example.com",
role: "admin",
});
await host.http.respond(
"https://api.example.com/report",
new Response(new Uint8Array([0, 255, 195, 40]), {
headers: { "content-type": "application/octet-stream" },
}),
);
await host.actions.routes.request("import-report", {
user: admin,
headers: { "X-EmDash-Request": "1" },
});
expect(host.http.requests()).toContainEqual(
expect.objectContaining({ url: "https://api.example.com/report" }),
);
respond() 立即消费并存储响应字节,因此后续 Worker Loader 请求会收到在其自身请求上下文中创建的响应。对同一 URL 的每次预期调用再排队一个响应。clear() 移除排队的响应与捕获的请求。
传递 rawBody,以通过生产路由解析器测试声明的 text、bytes 或 form-data 请求:
const form = new FormData();
form.append("title", "Quarterly report");
form.append("attachment", new File([new Uint8Array([0, 255])], "report.bin"));
const admin = await host.fixtures.user({
email: "plugin-test@example.com",
role: "admin",
});
const response = await host.actions.routes.request("import", {
method: "POST",
user: admin,
headers: { "X-EmDash-Request": "1" },
rawBody: form,
});
expect(response.ok).toBe(true);
rawBody 接受任何 BodyInit,包括字符串、Uint8Array、URLSearchParams 和 FormData。请求体被缓冲。对旧版 JSON 路径使用 body;测试主机将其序列化并设置 Content-Type: application/json。
运行时主机仅暴露已发布的操作。用于翻译、发布策略、Block Kit 交互、分类法、重定向、扩展评论管理、媒体字节与加密设置的 capability 特定辅助函数属于添加这些 capabilities 的版本。
测试边界
默认 Vitest 配置使用 Worker Loader,因为它是插件开发最快的生产沙箱路径。EmDash 也会对 Node.js workerd 运行器运行等效的运行时内容与重启旅程。当插件依赖对运行器敏感的行为时,添加单独的可选 Node/workerd 作业;生成的项目默认不运行两个运行器。
任一主机都不会渲染 EmDash 管理应用,也不会复现 Cloudflare 已部署的 CPU、内存与子请求限制。对浏览器旅程使用一次性 EmDash 站点,并在 Cloudflare 预览或暂存部署上验证对限制敏感的行为。
对 Block Kit 处理程序,使用 host.admin.loadPage() 或 loadWidget() 演练私有路由、主机证明的语言环境上下文、响应验证与 Worker Loader 隔离。对页面交互使用 admin.act() 和 admin.submit()。
已保存条目扩展使用生产所有权与路由权限边界。用 fixtures 创建集合、用户与内容,然后调用 admin.loadEditorPanel()、actEditorPanel()、submitEditorPanel() 或 invokeEditorAction()。为管理 UI 语言传递 locale,在选择翻译条目时传递 contentLocale。这些辅助函数在调用隔离之前重新加载已保存条目,并且从不发送未保存的字段值。
管理辅助函数不渲染 React。使用 Block Playground 或浏览器旅程验证 Kumo 渲染、确认对话框、键盘操作与从右到左布局。