Sandboxed Plugins testen

Auf dieser Seite

@emdash-cms/plugin-test baut ein Sandboxed Plugin und führt seine Tests über eine lokale Worker-Loader-Bindung aus. Tests nutzen EmDashs Produktions-Cloudflare-Sandbox-Wrapper und PluginBridge, mit lokalen D1- und Worker-Loader-Bindungen von @cloudflare/vitest-plugin.

Wählen Sie den Host, der zum getesteten Verhalten passt:

  • createPluginTestHost() ruft einen Hook oder eine Route direkt über den Sandbox-Transport auf. Nutzen Sie ihn für Serialisierung, Capability-Durchsetzung, Plugin-Storage und Route-Handler-Logik.
  • createPluginRuntimeTestHost() führt echte EmDash-Inhalts-, Plugin-Aktivierungs-, Medien-, Kommentar-, geplante Aufgaben- und Plugin-Route-Aktionen aus. Nutzen Sie ihn, wenn der Test beweisen muss, dass eine Host-Aktion das Plugin erreicht.

Von emdash-plugin init erstellte Projekte enthalten dieses Setup. Bestehende Plugin-Projekte können den Test-Host als Entwicklungsabhängigkeit installieren:

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

Wenn das Projekt Build-Skripte von Abhängigkeiten einschränkt, erlauben Sie workerd, seine Plattform-Binary zu installieren. Die generierte pnpm-Policy enthält diesen Eintrag:

allowBuilds:
  workerd: true

Vitest konfigurieren

Fügen Sie das EmDash-Test-Plugin zur Vitest-Konfiguration des Projekts hinzu:

import { emdashPluginTest } from "@emdash-cms/plugin-test/config";
import { defineConfig } from "vitest/config";

export default defineConfig({
	plugins: [emdashPluginTest()],
});

emdashPluginTest() führt den Plugin-Build aus, bevor Vitest startet. Es liest die generierte Runtime und das Manifest, erstellt eine isolierte D1-Datenbank und Worker-Loader-Bindung und exportiert dieselbe PluginBridge, die Cloudflare-Deployments nutzen. Übergeben Sie { dir: "./packages/gallery" }, wenn die Vitest-Konfiguration außerhalb des Plugin-Verzeichnisses liegt.

Sandbox-Transport testen

Erstellen und entsorgen Sie einen Host in jedem Test. Die Entsorgung stoppt das Plugin und setzt seine Test-Bindungen zurück:

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() akzeptiert einen Eingabewert und optionale Request-Eigenschaften. Die Standardanfrage ist ein POST an die Route des Plugins mit leeren Headern und Request-Metadaten.

Direkter Aufruf übt EmDash-Route-Authentifizierung, Berechtigung, Token-Scope, Cross-Site Request Forgery (CSRF) oder Cache-Policy nicht aus. Nutzen Sie host.actions.routes.request() auf dem Runtime-Host für diese Prüfungen.

Hooks und Storage testen

Rufen Sie Hooks mit der Event-Form auf, die sie von EmDash erhalten. Die Storage- und KV-Reader prüfen den über die Bridge geschriebenen Zustand:

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-Aufrufe setzen weiterhin die in emdash-plugin.jsonc deklarierten Collections durch. Content-, Media-, User-, Email- und Network-Aufrufe setzen weiterhin die deklarierten Capabilities und Allowed Hosts des Plugins durch.

Inhalt seeden

Erstellen Sie eine Collection und seeden Sie Einträge, bevor Sie eine Route oder einen Hook aufrufen, der Site-Inhalt liest:

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 });

Collection und Einträge nutzen die echte EmDash-Schema-Registry und das Content-Repository gegen D1.

Host-Aktionen testen

Erstellen Sie einen Runtime-Host, wenn das Ergebnis von EmDash-Orchestrierung abhängt. Fixtures schreiben Anfangszustand, ohne Plugin-Hooks auszulösen. Actions rufen die Produktions-Runtime oder Handler-Grenze auf, und Inspectors lesen beobachtbaren Zustand, ohne Plugin-Code aufzurufen.

Für ein Plugin, dessen content:beforeSave-Hook [checked] an den Titel anhängt, beweist der folgende Test, dass ein Content-Save den Hook erreicht:

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]" } } },
		});
	});
});

Der Runtime-Host gruppiert seine API nach Grenze:

  • transport ruft das Isolate direkt für transportbezogene Prüfungen auf.
  • admin lädt validierte Block-Kit-Seiten und Widgets, sendet Formulare und ruft Actions über die Produktions-Route-Grenze mit host-attestiertem Locale-Kontext auf.
  • fixtures erstellt Site-, Collection-, Field-, User-, Byline-, Taxonomy-, Content-, Redirect-, Binary-Media- und Plugin-Zustand, ohne Hooks auszulösen.
  • actions führt Content-Zustandsänderungen, Plugin-Aktivierung und -Deaktivierung, generierte Settings-Updates, Media-Uploads, öffentliche Kommentarübermittlung, Kommentarmoderation und policy-geprüfte Plugin-Routes aus.
  • inspect liest Content, Redirects, Byline-Credits, Taxonomy-Zuweisungen, Plugin-Storage, KV, rohe persistierte Setting-Envelopes, Plugin-Zustand, geplante Aufgaben, Publication-Policy-Ablehnungen, Media-Metadaten und Bytes, Kommentare und erfasste E-Mails. Nutzen Sie inspect.scheduledPolicyRejections(), um zu prüfen, dass ein Scheduler-Veto für Administratoraufmerksamkeit persistiert wurde.
  • scheduled steuert die effektive Zeit für Cron-Aufgaben und geplantes Veröffentlichen und führt dann einen Produktions-Wartungsbatch aus.
  • http stellt externe Antworten in die Warteschlange und erfasst die Anfragen, die ein Plugin über ctx.http.fetch() sendet.
  • restart() ersetzt Runtime und Isolate, behält aber D1, Plugin-Storage, Media-Storage und Plugin-Zustand.

Rufen Sie nach jedem Test dispose() auf. Die Entsorgung beendet das Isolate und setzt alle Bindungen zurück, sodass ein späterer Test die Datenbank oder Medien des vorherigen Hosts nicht beobachten kann.

Nutzen Sie die Settings-Action und den Raw-Inspector, um zu beweisen, dass ein generiertes Settings-Save den Produktions-Handler erreicht und keinen Klartext persistiert:

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");

Setzen Sie EMDASH_ENCRYPTION_KEY im Testprozess, bevor Sie den Host erstellen. Der Raw-Inspector gibt absichtlich das persistierte Envelope zurück; nutzen Sie die Route oder den Hook des Plugins, um den entschlüsselten ctx.settings-Wert zu prüfen.

Nutzen Sie host.fixtures.redirect(), um Redirect-Zustand zu etablieren, ohne das Plugin aufzurufen. Nutzen Sie host.inspect.redirects(), um die persistierten Regeln zu prüfen, nachdem das Plugin ctx.redirects aufruft.

Nutzen Sie ein Binary-Fixture, um media:bytes:read über den Storage-Adapter der Runtime und die Worker-Loader-Bridge zu testen. Das folgende Fixture meldet absichtlich eine kleinere Datenbankgröße, damit der Test beweisen kann, dass das Stream-Limit maßgeblich ist:

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]),
);

Nutzen Sie createPluginRuntimeTestHost() für die Erstellung von Übersetzungen. Der direkte Transport-Host führt nicht den runtime-eigenen Übersetzungs-Lebenszyklus aus, der gemeinsame Felder und Attribution kopiert.

Stellen Sie eine Binary-Antwort in die Warteschlange, bevor Sie die Plugin-Route aufrufen, die sie abruft:

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() konsumiert und speichert die Antwort-Bytes sofort, sodass die spätere Worker-Loader-Anfrage eine in ihrem eigenen Request-Kontext erstellte Antwort erhält. Stellen Sie für jeden erwarteten Aufruf derselben URL eine weitere Antwort in die Warteschlange. clear() entfernt wartende Antworten und erfasste Anfragen.

Übergeben Sie rawBody, um eine deklarierte text-, bytes- oder form-data-Anfrage über den Produktions- Route-Parser zu testen:

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 akzeptiert jedes BodyInit, einschließlich Strings, Uint8Array, URLSearchParams und FormData. Der Request-Body wird gepuffert. Nutzen Sie body für den Legacy-JSON-Pfad; der Test-Host serialisiert ihn und setzt Content-Type: application/json.

Der Runtime-Host stellt nur ausgelieferte Operationen bereit. Capability-spezifische Helfer für Übersetzungen, Publication Policy, Block-Kit-Interaktionen, Taxonomien, Redirects, erweiterte Kommentarverwaltung, Media-Bytes und verschlüsselte Settings gehören zu den Releases, die diese Capabilities hinzufügen.

Testgrenzen

Die Standard-Vitest-Konfiguration nutzt Worker Loader, weil es der schnellste Produktions-Sandbox-Pfad für die Plugin-Entwicklung ist. EmDash führt auch äquivalente Runtime-Content- und Restart-Journeys gegen den Node.js-workerd-Runner aus. Fügen Sie einen separaten, opt-in Node/workerd-Job hinzu, wenn ein Plugin von runner-sensitivem Verhalten abhängt; das generierte Projekt führt standardmäßig nicht beide Runner aus.

Kein Host rendert die EmDash-Admin-Anwendung oder reproduziert Cloudflares deployte CPU-, Speicher- und Subrequest-Limits. Nutzen Sie eine Wegwerf-EmDash-Site für Browser-Journeys und prüfen Sie limit-sensitives Verhalten auf einem Cloudflare-Preview- oder Staging-Deployment.

Für Block-Kit-Handler nutzen Sie host.admin.loadPage() oder loadWidget(), um die private Route, den host-attestierten Locale-Kontext, die Antwortvalidierung und das Worker-Loader-Isolate zu üben. Nutzen Sie admin.act() und admin.submit() für Seiteninteraktionen.

Saved-Entry-Extensions nutzen die Produktions-Ownership- und Route-Permission-Grenze. Erstellen Sie Collection, User und Content mit Fixtures und rufen Sie dann admin.loadEditorPanel(), actEditorPanel(), submitEditorPanel() oder invokeEditorAction() auf. Übergeben Sie locale für die Admin-UI-Sprache und contentLocale beim Auswählen eines übersetzten Eintrags. Diese Helfer laden den gespeicherten Eintrag neu, bevor sie das Isolate aufrufen, und senden niemals ungespeicherte Feldwerte.

Die Admin-Helfer rendern kein React. Nutzen Sie den Block Playground oder eine Browser-Journey, um Kumo-Rendering, Bestätigungsdialoge, Tastaturbedienung und Rechts-nach-links-Layout zu prüfen.