Testar plugins sandboxed

Nesta página

@emdash-cms/plugin-test constrói um plugin sandboxed e executa seus testes por um binding local de Worker Loader. Os testes usam o wrapper de sandbox Cloudflare de produção do EmDash e PluginBridge, com bindings locais de D1 e Worker Loader fornecidos por @cloudflare/vitest-plugin.

Escolha o host que corresponde ao comportamento sob teste:

  • createPluginTestHost() invoca um hook ou rota diretamente pelo transporte do sandbox. Use-o para serialização, enforcement de capabilities, armazenamento do plugin e lógica de manipuladores de rota.
  • createPluginRuntimeTestHost() executa ações reais de conteúdo EmDash, ativação de plugins, mídia, comentários, tarefas agendadas e rotas de plugins. Use-o quando o teste precisar provar que uma ação do host alcança o plugin.

Projetos criados por emdash-plugin init incluem esta configuração. Projetos de plugins existentes podem instalar o host de teste como dependência de desenvolvimento:

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

Se o projeto restringir scripts de build de dependências, permita que workerd instale seu binário de plataforma. A política pnpm gerada inclui esta entrada:

allowBuilds:
  workerd: true

Configurar o Vitest

Adicione o plugin de teste EmDash à configuração Vitest do projeto:

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

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

emdashPluginTest() executa o build do plugin antes de o Vitest iniciar. Ele lê o runtime e o manifesto gerados, cria um banco D1 isolado e um binding Worker Loader, e exporta o mesmo PluginBridge usado pelos deployments Cloudflare. Passe { dir: "./packages/gallery" } quando a configuração Vitest ficar fora do diretório do plugin.

Testar o transporte do sandbox

Crie e disponha um host dentro de cada teste. A disposição para o plugin e redefine seus bindings de teste:

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() aceita um valor de entrada e propriedades de solicitação opcionais. A solicitação padrão é um POST à rota do plugin com cabeçalhos e metadados de solicitação vazios.

A invocação direta não exercita autenticação de rota EmDash, permissão, escopo de token, cross-site request forgery (CSRF) nem política de cache. Use host.actions.routes.request() no host de runtime para essas verificações.

Testar hooks e armazenamento

Invoque hooks com a forma de evento que recebem do EmDash. Os leitores de armazenamento e KV inspecionam o estado escrito pelo 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",
});

Chamadas de armazenamento ainda aplicam as collections declaradas em emdash-plugin.jsonc. Chamadas de conteúdo, mídia, usuário, e-mail e rede ainda aplicam as capabilities e hosts permitidos declarados do plugin.

Semear conteúdo

Crie uma collection e semeie entradas antes de invocar uma rota ou hook que leia conteúdo do 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 });

A collection e as entradas usam o registro de schemas real do EmDash e o repositório de conteúdo contra D1.

Testar ações do host

Crie um host de runtime quando o resultado depender da orquestração do EmDash. Fixtures escrevem o estado inicial sem disparar hooks do plugin. Actions chamam o runtime de produção ou a fronteira do manipulador, e inspectors leem o estado observável sem invocar código do plugin.

Para um plugin cujo hook content:beforeSave acrescenta [checked] ao título, o teste a seguir prova que um salvamento de conteúdo alcança o 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]" } } },
		});
	});
});

O host de runtime agrupa sua API por fronteira:

  • transport invoca diretamente o isolate para verificações no nível do transporte.
  • admin carrega páginas e widgets Block Kit validados, envia formulários e invoca actions pela fronteira de rota de produção com contexto de locale atestado pelo host.
  • fixtures cria site, collection, campo, usuário, byline, taxonomia, conteúdo, redirecionamento, mídia binária e estado do plugin sem disparar hooks.
  • actions executa mudanças de estado de conteúdo, ativação e desativação de plugins, atualizações de configurações geradas, uploads de mídia, envio público de comentários, moderação de comentários e rotas de plugins verificadas por política.
  • inspect lê conteúdo, redirecionamentos, créditos de byline, atribuições de taxonomia, armazenamento do plugin, KV, envelopes brutos de configurações persistidas, estado do plugin, tarefas agendadas, rejeições de política de publicação, metadados e bytes de mídia, comentários e e-mail capturado. Use inspect.scheduledPolicyRejections() para verificar que um veto do agendador foi persistido para atenção do administrador.
  • scheduled controla o tempo efetivo para tarefas cron e publicação agendada e então executa um lote de manutenção de produção.
  • http enfileira respostas externas e captura as solicitações que um plugin envia por ctx.http.fetch().
  • restart() substitui o runtime e o isolate mantendo D1, armazenamento do plugin, armazenamento de mídia e estado do plugin.

Chame dispose() após cada teste. A disposição termina o isolate e redefine todos os bindings, de modo que um teste posterior não possa observar o banco ou a mídia do host anterior.

Use a action de configurações e o inspector bruto para provar que um salvamento de configurações geradas alcança o manipulador de produção e não persiste texto em claro:

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

Defina EMDASH_ENCRYPTION_KEY no processo de teste antes de criar o host. O inspector bruto deliberadamente retorna o envelope persistido; use a rota ou o hook do plugin para verificar o valor descriptografado de ctx.settings.

Use host.fixtures.redirect() para estabelecer o estado de redirecionamento sem invocar o plugin. Use host.inspect.redirects() para afirmar as regras persistidas depois que o plugin chama ctx.redirects.

Use um fixture binário para testar media:bytes:read pelo adaptador de armazenamento do runtime e o bridge Worker Loader. O fixture a seguir deliberadamente reporta um tamanho de banco menor para que o teste possa provar que o limite do stream é autoritativo:

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

Use createPluginRuntimeTestHost() para criação de traduções. O host de transporte direto não executa o ciclo de vida de tradução de propriedade do runtime que copia campos compartilhados e atribuição.

Enfileire uma resposta binária antes de invocar a rota do plugin que a busca:

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() consome e armazena os bytes da resposta imediatamente, de modo que a solicitação posterior do Worker Loader receba uma resposta criada em seu próprio contexto de solicitação. Enfileire outra resposta para cada chamada esperada à mesma URL. clear() remove respostas enfileiradas e solicitações capturadas.

Passe rawBody para testar uma solicitação declarada text, bytes ou form-data pelo analisador de rota de produção:

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 aceita qualquer BodyInit, incluindo strings, Uint8Array, URLSearchParams e FormData. O corpo da solicitação é bufferizado. Use body para o caminho JSON legado; o host de teste o serializa e define Content-Type: application/json.

O host de runtime expõe apenas operações enviadas. Helpers específicos de capability para traduções, política de publicação, interações Block Kit, taxonomias, redirecionamentos, administração ampliada de comentários, bytes de mídia e configurações criptografadas pertencem aos releases que adicionam essas capabilities.

Fronteiras dos testes

A configuração Vitest padrão usa Worker Loader porque é o caminho de sandbox de produção mais rápido para desenvolvimento de plugins. O EmDash também executa jornadas equivalentes de conteúdo de runtime e reinício contra o runner workerd do Node.js. Adicione um job Node/workerd separado e opt-in quando um plugin depender de comportamento sensível ao runner; o projeto gerado não executa ambos os runners por padrão.

Nenhum host renderiza o aplicativo de administração EmDash nem reproduz os limites de CPU, memória e subrequest implantados do Cloudflare. Use um site EmDash descartável para jornadas de navegador e verifique o comportamento sensível a limites em um deployment de preview ou staging do Cloudflare.

Para manipuladores Block Kit, use host.admin.loadPage() ou loadWidget() para exercitar a rota privada, o contexto de locale atestado pelo host, a validação de resposta e o isolate Worker Loader. Use admin.act() e admin.submit() para interações de página.

Extensões de entrada salva usam a fronteira de ownership e permissão de rota de produção. Crie a collection, o usuário e o conteúdo com fixtures e então chame admin.loadEditorPanel(), actEditorPanel(), submitEditorPanel() ou invokeEditorAction(). Passe locale para o idioma da UI de administração e contentLocale ao selecionar uma entrada traduzida. Esses helpers recarregam a entrada salva antes de invocar o isolate e nunca enviam valores de campo não salvos.

Os helpers de administração não renderizam React. Use o Block Playground ou uma jornada de navegador para verificar renderização Kumo, diálogos de confirmação, operação por teclado e layout da direita para a esquerda.