Testare i plugin sandboxed

In questa pagina

@emdash-cms/plugin-test costruisce un plugin sandboxed ed esegue i suoi test tramite un binding Worker Loader locale. I test usano il wrapper sandbox Cloudflare di produzione di EmDash e PluginBridge, con binding D1 e Worker Loader locali forniti da @cloudflare/vitest-plugin.

Scegli l’host che corrisponde al comportamento sotto test:

  • createPluginTestHost() invoca un hook o una route direttamente attraverso il trasporto sandbox. Usalo per serializzazione, enforcement delle capability, storage del plugin e logica dei gestori di route.
  • createPluginRuntimeTestHost() esegue azioni reali EmDash di contenuto, attivazione plugin, media, commenti, attività pianificate e route dei plugin. Usalo quando il test deve dimostrare che un’azione dell’host raggiunge il plugin.

I progetti creati da emdash-plugin init includono questa configurazione. I progetti di plugin esistenti possono installare l’host di test come dipendenza di sviluppo:

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

Se il progetto restringe gli script di build delle dipendenze, consenti a workerd di installare il suo binario di piattaforma. La policy pnpm generata include questa voce:

allowBuilds:
  workerd: true

Configurare Vitest

Aggiungi il plugin di test EmDash alla configurazione Vitest del progetto:

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

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

emdashPluginTest() esegue la build del plugin prima che Vitest si avvii. Legge il runtime e il manifest generati, crea un database D1 isolato e un binding Worker Loader, ed esporta lo stesso PluginBridge usato dalle distribuzioni Cloudflare. Passa { dir: "./packages/gallery" } quando la configurazione Vitest si trova fuori dalla directory del plugin.

Testare il trasporto sandbox

Crea e disponi un host in ogni test. La disposizione ferma il plugin e reimposta i suoi binding di 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() accetta un valore di input e proprietà di richiesta opzionali. La richiesta predefinita è un POST alla route del plugin con intestazioni e metadati di richiesta vuoti.

L’invocazione diretta non esercita autenticazione delle route EmDash, permessi, ambito del token, cross-site request forgery (CSRF) né policy di cache. Usa host.actions.routes.request() sull’host runtime per quei controlli.

Testare hook e storage

Invoca gli hook con la forma di evento che ricevono da EmDash. I lettori di storage e KV ispezionano lo stato scritto tramite il 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",
});

Le chiamate di storage applicano ancora le collection dichiarate in emdash-plugin.jsonc. Le chiamate di contenuto, media, utenti, email e rete applicano ancora le capability e gli host consentiti dichiarati del plugin.

Seed del contenuto

Crea una collection e fai seed delle voci prima di invocare una route o un hook che legge il contenuto del sito:

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 e le voci usano il vero registro degli schemi EmDash e il repository di contenuto contro D1.

Testare le azioni dell’host

Crea un host runtime quando il risultato dipende dall’orchestrazione EmDash. I fixture scrivono lo stato iniziale senza attivare gli hook del plugin. Le actions chiamano il runtime di produzione o il confine del gestore, e gli inspector leggono lo stato osservabile senza invocare il codice del plugin.

Per un plugin il cui hook content:beforeSave aggiunge [checked] al titolo, il seguente test dimostra che un salvataggio di contenuto raggiunge l’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’host runtime raggruppa la sua API per confine:

  • transport invoca direttamente l’isolate per controlli a livello di trasporto.
  • admin carica pagine e widget Block Kit validati, invia form e invoca actions attraverso il confine di route di produzione con contesto di locale attestato dall’host.
  • fixtures crea sito, collection, campo, utente, byline, tassonomia, contenuto, redirect, media binari e stato del plugin senza attivare hook.
  • actions esegue cambiamenti di stato del contenuto, attivazione e disattivazione dei plugin, aggiornamenti delle impostazioni generate, caricamenti media, invio pubblico di commenti, moderazione dei commenti e route dei plugin controllate da policy.
  • inspect legge contenuto, redirect, crediti byline, assegnazioni di tassonomia, storage del plugin, KV, envelope delle impostazioni persistiti grezzi, stato del plugin, attività pianificate, rifiuti di policy di pubblicazione, metadati e byte dei media, commenti ed email catturate. Usa inspect.scheduledPolicyRejections() per verificare che un veto dello scheduler sia stato persistito per l’attenzione dell’amministratore.
  • scheduled controlla l’ora effettiva per le attività cron e la pubblicazione pianificata, poi esegue un batch di manutenzione di produzione.
  • http mette in coda risposte esterne e cattura le richieste che un plugin invia tramite ctx.http.fetch().
  • restart() sostituisce runtime e isolate mantenendo D1, storage del plugin, storage dei media e stato del plugin.

Chiama dispose() dopo ogni test. La disposizione termina l’isolate e reimposta tutti i binding, così un test successivo non può osservare il database o i media dell’host precedente.

Usa l’action delle impostazioni e l’inspector grezzo per dimostrare che un salvataggio di impostazioni generate raggiunge il gestore di produzione e non persiste testo in chiaro:

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

Imposta EMDASH_ENCRYPTION_KEY nel processo di test prima di creare l’host. L’inspector grezzo restituisce deliberatamente l’envelope persistito; usa la route o l’hook del plugin per verificare il valore decifrato di ctx.settings.

Usa host.fixtures.redirect() per stabilire lo stato di redirect senza invocare il plugin. Usa host.inspect.redirects() per affermare le regole persistite dopo che il plugin chiama ctx.redirects.

Usa un fixture binario per testare media:bytes:read tramite l’adapter di storage del runtime e il bridge Worker Loader. Il seguente fixture riporta deliberatamente una dimensione del database più piccola così il test può dimostrare che il limite dello stream è autorevole:

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

Usa createPluginRuntimeTestHost() per la creazione di traduzioni. L’host di trasporto diretto non esegue il ciclo di vita delle traduzioni di proprietà del runtime che copia campi condivisi e attribuzione.

Metti in coda una risposta binaria prima di invocare la route del plugin che la recupera:

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() consuma e memorizza i byte della risposta immediatamente, così la successiva richiesta Worker Loader riceve una risposta creata nel proprio contesto di richiesta. Metti in coda un’altra risposta per ogni chiamata attesa alla stessa URL. clear() rimuove le risposte in coda e le richieste catturate.

Passa rawBody per testare una richiesta dichiarata text, bytes o form-data tramite il parser di route di produzione:

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 accetta qualsiasi BodyInit, inclusi stringhe, Uint8Array, URLSearchParams e FormData. Il corpo della richiesta è bufferizzato. Usa body per il percorso JSON legacy; l’host di test lo serializza e imposta Content-Type: application/json.

L’host runtime espone solo operazioni spedite. Gli helper specifici delle capability per traduzioni, policy di pubblicazione, interazioni Block Kit, tassonomie, redirect, amministrazione commenti ampliata, byte dei media e impostazioni crittografate appartengono ai release che aggiungono quelle capability.

Confini dei test

La configurazione Vitest predefinita usa Worker Loader perché è il percorso sandbox di produzione più veloce per lo sviluppo di plugin. EmDash esegue anche journey equivalenti di contenuto runtime e riavvio contro il runner workerd di Node.js. Aggiungi un job Node/workerd separato e opt-in quando un plugin dipende da comportamento sensibile al runner; il progetto generato non esegue entrambi i runner per impostazione predefinita.

Nessun host renderizza l’applicazione di amministrazione EmDash né riproduce i limiti di CPU, memoria e subrequest distribuiti di Cloudflare. Usa un sito EmDash usa e getta per i journey del browser, e verifica il comportamento sensibile ai limiti su una distribuzione di anteprima o staging Cloudflare.

Per i gestori Block Kit, usa host.admin.loadPage() o loadWidget() per esercitare la route privata, il contesto di locale attestato dall’host, la validazione della risposta e l’isolate Worker Loader. Usa admin.act() e admin.submit() per le interazioni di pagina.

Le estensioni di voci salvate usano il confine di ownership e permesso di route di produzione. Crea collection, utente e contenuto con i fixture, poi chiama admin.loadEditorPanel(), actEditorPanel(), submitEditorPanel() o invokeEditorAction(). Passa locale per la lingua dell’UI di amministrazione e contentLocale quando selezioni una voce tradotta. Questi helper ricaricano la voce salvata prima di invocare l’isolate e non inviano mai valori di campo non salvati.

Gli helper di amministrazione non renderizzano React. Usa il Block Playground o un journey del browser per verificare il rendering Kumo, le finestre di conferma, l’operazione da tastiera e il layout da destra a sinistra.