サンドボックスプラグインのテスト

このページ

@emdash-cms/plugin-test はサンドボックスプラグインをビルドし、ローカルの Worker Loader バインディング経由でテストを実行します。テストは EmDash の本番 Cloudflare サンドボックスラッパーと PluginBridge を使い、@cloudflare/vitest-plugin が提供するローカル D1 と Worker Loader バインディングを利用します。

テスト対象の動作に合うホストを選びます。

  • createPluginTestHost() はサンドボックス転送を介してフックまたはルートを直接呼び出します。シリアライズ、capability の強制、プラグインストレージ、ルートハンドラーロジックに使います。
  • createPluginRuntimeTestHost() は実際の EmDash コンテンツ、プラグイン有効化、メディア、コメント、スケジュールタスク、プラグインルートのアクションを実行します。ホストアクションがプラグインに到達することを証明する必要があるときに使います。

emdash-plugin init で作成されたプロジェクトにはこのセットアップが含まれます。既存のプラグインプロジェクトは、開発依存関係としてテストホストをインストールできます。

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

プロジェクトが依存関係のビルドスクリプトを制限している場合は、workerd がプラットフォームバイナリをインストールできるようにします。生成された pnpm ポリシーには次のエントリが含まれます。

allowBuilds:
  workerd: true

Vitest を設定する

プロジェクトの Vitest 設定に EmDash テストプラグインを追加します。

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() を使います。

フックとストレージをテストする

EmDash から受け取るイベント形状でフックを呼び出します。ストレージと 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 で宣言されたコレクションを強制します。コンテンツ、メディア、ユーザー、メール、ネットワークの呼び出しは引き続きプラグインの宣言された capability と許可ホストを強制します。

コンテンツをシードする

サイトコンテンツを読むルートやフックを呼び出す前に、コレクションを作成しエントリをシードします。

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 スキーマレジストリとコンテンツリポジトリを使います。

ホストアクションをテストする

結果が EmDash のオーケストレーションに依存する場合はランタイムホストを作成します。フィクスチャはプラグインフックを発火せずに初期状態を書き込みます。アクションは本番ランタイムまたはハンドラー境界を呼び出し、インスペクターはプラグインコードを呼び出さずに観測可能な状態を読みます。

content:beforeSave フックがタイトルに [checked] を付加するプラグインでは、次のテストがコンテンツ保存がフックに到達することを証明します。

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 はフックを発火せずにサイト、コレクション、フィールド、ユーザー、バイライン、タクソノミー、コンテンツ、リダイレクト、バイナリメディア、プラグイン状態を作成します。
  • actions はコンテンツ状態変更、プラグインの有効化と無効化、生成された設定更新、メディアアップロード、公開コメント送信、コメントモデレーション、ポリシー確認済みプラグインルートを実行します。
  • inspect はコンテンツ、リダイレクト、バイラインクレジット、タクソノミー割り当て、プラグインストレージ、KV、生の永続設定エンベロープ、プラグイン状態、スケジュールタスク、公開ポリシー拒否、メディアメタデータとバイト、コメント、キャプチャされたメールを読みます。スケジューラ拒否が管理者の注意のために永続化されたことを確認するには inspect.scheduledPolicyRejections() を使います。
  • scheduled は cron タスクとスケジュール公開の実効時刻を制御し、本番メンテナンバッチを 1 回実行します。
  • 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 を設定します。生インスペクターは意図的に永続化されたエンベロープを返します。復号された ctx.settings 値を確認するにはプラグインのルートまたはフックを使います。

プラグインを呼び出さずにリダイレクト状態を確立するには host.fixtures.redirect() を使います。プラグインが ctx.redirects を呼び出した後、永続化されたルールをアサートするには host.inspect.redirects() を使います。

ランタイムのストレージアダプターと Worker Loader ブリッジ経由で media:bytes:read をテストするにはバイナリフィクスチャを使います。次のフィクスチャは意図的に小さなデータベースサイズを報告し、ストリーム制限が権威であることをテストが証明できるようにします。

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() はキューされたレスポンスとキャプチャされたリクエストを削除します。

宣言された text、bytes、または form-data リクエストを本番ルートパーサー経由でテストするには rawBody を渡します。

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 は文字列、Uint8Array、URLSearchParams、FormData を含む任意の BodyInit を受け付けます。リクエストボディはバッファされます。レガシー JSON パスには body を使います。テストホストはそれをシリアライズし、Content-Type: application/json を設定します。

ランタイムホストは出荷済み操作のみを公開します。翻訳、公開ポリシー、Block Kit インタラクション、タクソノミー、リダイレクト、拡張コメント管理、メディアバイト、暗号化設定向けの capability 固有ヘルパーは、それらの capability を追加するリリースに属します。

テスト境界

デフォルトの 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() を使います。

保存済みエントリ拡張は本番の所有権とルート権限境界を使います。フィクスチャでコレクション、ユーザー、コンテンツを作成し、admin.loadEditorPanel()、actEditorPanel()、submitEditorPanel()、または invokeEditorAction() を呼び出します。管理 UI 言語には locale、翻訳エントリを選ぶときは contentLocale を渡します。これらのヘルパーはアイソレートを呼び出す前に保存済みエントリを再読み込みし、未保存のフィールド値を送ることはありません。

管理ヘルパーは React をレンダリングしません。Kumo レンダリング、確認ダイアログ、キーボード操作、右から左へのレイアウトを確認するには、Block Playground またはブラウザジャーニーを使います。