Tester les plugins sandboxed

Sur cette page

@emdash-cms/plugin-test construit un plugin sandboxed et exécute ses tests via une liaison Worker Loader locale. Les tests utilisent le wrapper sandbox Cloudflare de production d’EmDash et PluginBridge, avec des liaisons D1 et Worker Loader locales fournies par @cloudflare/vitest-plugin.

Choisissez l’hôte qui correspond au comportement sous test :

  • createPluginTestHost() invoque un hook ou une route directement à travers le transport sandbox. Utilisez-le pour la sérialisation, l’application des capabilities, le stockage du plugin et la logique des gestionnaires de routes.
  • createPluginRuntimeTestHost() exécute de vraies actions EmDash de contenu, d’activation de plugins, de médias, de commentaires, de tâches planifiées et de routes de plugins. Utilisez-le lorsque le test doit prouver qu’une action de l’hôte atteint le plugin.

Les projets créés par emdash-plugin init incluent cette configuration. Les projets de plugins existants peuvent installer l’hôte de test comme dépendance de développement :

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

Si le projet restreint les scripts de build des dépendances, autorisez workerd à installer son binaire de plateforme. La politique pnpm générée inclut cette entrée :

allowBuilds:
  workerd: true

Configurer Vitest

Ajoutez le plugin de test EmDash à la configuration Vitest du projet :

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

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

emdashPluginTest() exécute le build du plugin avant le démarrage de Vitest. Il lit le runtime et le manifest générés, crée une base D1 isolée et une liaison Worker Loader, et exporte le même PluginBridge utilisé par les déploiements Cloudflare. Passez { dir: "./packages/gallery" } lorsque la configuration Vitest se trouve hors du répertoire du plugin.

Tester le transport sandbox

Créez et disposez un hôte dans chaque test. La disposition arrête le plugin et réinitialise ses liaisons de test :

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() accepte une valeur d’entrée et des propriétés de requête optionnelles. La requête par défaut est un POST vers la route du plugin avec des en-têtes et des métadonnées de requête vides.

L’invocation directe n’exerce pas l’authentification de route EmDash, les permissions, la portée du jeton, le cross-site request forgery (CSRF) ni la politique de cache. Utilisez host.actions.routes.request() sur l’hôte runtime pour ces contrôles.

Tester les hooks et le stockage

Invoquez les hooks avec la forme d’événement qu’ils reçoivent d’EmDash. Les lecteurs de stockage et de KV inspectent l’état écrit via le bridge :

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

Les appels de stockage appliquent toujours les collections déclarées dans emdash-plugin.jsonc. Les appels de contenu, médias, utilisateurs, e-mail et réseau appliquent toujours les capabilities et hôtes autorisés déclarés du plugin.

Amorcer le contenu

Créez une collection et amorcez des entrées avant d’invoquer une route ou un hook qui lit le contenu du site :

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

La collection et les entrées utilisent le vrai registre de schémas EmDash et le dépôt de contenu contre D1.

Tester les actions de l’hôte

Créez un hôte runtime lorsque le résultat dépend de l’orchestration EmDash. Les fixtures écrivent l’état initial sans déclencher les hooks du plugin. Les actions appellent le runtime de production ou la frontière du gestionnaire, et les inspectors lisent l’état observable sans invoquer le code du plugin.

Pour un plugin dont le hook content:beforeSave ajoute [checked] au titre, le test suivant prouve qu’une sauvegarde de contenu atteint le 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]" } } },
		});
	});
});

L’hôte runtime regroupe son API par frontière :

  • transport invoque directement l’isolate pour des contrôles au niveau du transport.
  • admin charge des pages et widgets Block Kit validés, soumet des formulaires et invoque des actions via la frontière de route de production avec un contexte de locale attesté par l’hôte.
  • fixtures crée site, collection, champ, utilisateur, byline, taxonomie, contenu, redirection, médias binaires et état du plugin sans déclencher de hooks.
  • actions exécute des changements d’état de contenu, l’activation et la désactivation de plugins, les mises à jour de paramètres générés, les téléversements de médias, la soumission publique de commentaires, la modération de commentaires et les routes de plugins vérifiées par politique.
  • inspect lit le contenu, les redirections, les crédits byline, les assignations de taxonomie, le stockage du plugin, le KV, les envelopes de paramètres persistés bruts, l’état du plugin, les tâches planifiées, les rejets de politique de publication, les métadonnées et octets de médias, les commentaires et les e-mails capturés. Utilisez inspect.scheduledPolicyRejections() pour vérifier qu’un veto du planificateur a été persisté pour l’attention de l’administrateur.
  • scheduled contrôle le temps effectif pour les tâches cron et la publication planifiée, puis exécute un lot de maintenance de production.
  • http met en file des réponses externes et capture les requêtes qu’un plugin envoie via ctx.http.fetch().
  • restart() remplace le runtime et l’isolate tout en conservant D1, le stockage du plugin, le stockage des médias et l’état du plugin.

Appelez dispose() après chaque test. La disposition termine l’isolate et réinitialise toutes les liaisons, de sorte qu’un test ultérieur ne puisse pas observer la base ou les médias de l’hôte précédent.

Utilisez l’action de paramètres et l’inspector brut pour prouver qu’une sauvegarde de paramètres générée atteint le gestionnaire de production et ne persiste pas de texte en clair :

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

Définissez EMDASH_ENCRYPTION_KEY dans le processus de test avant de créer l’hôte. L’inspector brut renvoie délibérément l’envelope persisté ; utilisez la route ou le hook du plugin pour vérifier la valeur déchiffrée de ctx.settings.

Utilisez host.fixtures.redirect() pour établir l’état de redirection sans invoquer le plugin. Utilisez host.inspect.redirects() pour affirmer les règles persistées après que le plugin appelle ctx.redirects.

Utilisez une fixture binaire pour tester media:bytes:read via l’adaptateur de stockage du runtime et le bridge Worker Loader. La fixture suivante signale délibérément une taille de base plus petite afin que le test puisse prouver que la limite du flux fait autorité :

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

Utilisez createPluginRuntimeTestHost() pour la création de traductions. L’hôte de transport direct n’exécute pas le cycle de vie de traduction appartenant au runtime qui copie les champs partagés et l’attribution.

Mettez en file une réponse binaire avant d’invoquer la route du plugin qui la récupère :

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() consomme et stocke les octets de réponse immédiatement, de sorte que la requête Worker Loader ultérieure reçoive une réponse créée dans son propre contexte de requête. Mettez en file une autre réponse pour chaque appel attendu à la même URL. clear() supprime les réponses en file et les requêtes capturées.

Passez rawBody pour tester une requête déclarée text, bytes ou form-data via l’analyseur de route de production :

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 accepte tout BodyInit, y compris les chaînes, Uint8Array, URLSearchParams et FormData. Le corps de la requête est mis en tampon. Utilisez body pour le chemin JSON hérité ; l’hôte de test le sérialise et définit Content-Type: application/json.

L’hôte runtime n’expose que les opérations livrées. Les helpers spécifiques aux capabilities pour les traductions, la politique de publication, les interactions Block Kit, les taxonomies, les redirections, l’administration élargie des commentaires, les octets de médias et les paramètres chiffrés appartiennent aux releases qui ajoutent ces capabilities.

Frontières des tests

La configuration Vitest par défaut utilise Worker Loader car c’est le chemin sandbox de production le plus rapide pour le développement de plugins. EmDash exécute aussi des journeys équivalents de contenu runtime et de redémarrage contre le runner workerd de Node.js. Ajoutez un job Node/workerd séparé et opt-in lorsqu’un plugin dépend d’un comportement sensible au runner ; le projet généré n’exécute pas les deux runners par défaut.

Aucun hôte ne rend l’application d’administration EmDash ni ne reproduit les limites CPU, mémoire et de sous-requêtes déployées de Cloudflare. Utilisez un site EmDash jetable pour les journeys navigateur, et vérifiez le comportement sensible aux limites sur un déploiement de prévisualisation ou de staging Cloudflare.

Pour les gestionnaires Block Kit, utilisez host.admin.loadPage() ou loadWidget() pour exercer la route privée, le contexte de locale attesté par l’hôte, la validation de réponse et l’isolate Worker Loader. Utilisez admin.act() et admin.submit() pour les interactions de page.

Les extensions d’entrée enregistrée utilisent la frontière d’ownership et de permission de route de production. Créez la collection, l’utilisateur et le contenu avec des fixtures, puis appelez admin.loadEditorPanel(), actEditorPanel(), submitEditorPanel() ou invokeEditorAction(). Passez locale pour la langue de l’UI d’administration et contentLocale lors de la sélection d’une entrée traduite. Ces helpers rechargent l’entrée enregistrée avant d’invoquer l’isolate et n’envoient jamais de valeurs de champs non enregistrées.

Les helpers d’administration ne rendent pas React. Utilisez le Block Playground ou un journey navigateur pour vérifier le rendu Kumo, les boîtes de dialogue de confirmation, le fonctionnement au clavier et la mise en page de droite à gauche.