測試沙箱外掛

本頁內容

@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 渲染、確認對話框、鍵盤操作與從右到左版面。