@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:
transportinvoca diretamente o isolate para verificações no nível do transporte.admincarrega 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.fixturescria site, collection, campo, usuário, byline, taxonomia, conteúdo, redirecionamento, mídia binária e estado do plugin sem disparar hooks.actionsexecuta 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.inspectlê 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. Useinspect.scheduledPolicyRejections()para verificar que um veto do agendador foi persistido para atenção do administrador.scheduledcontrola o tempo efetivo para tarefas cron e publicação agendada e então executa um lote de manutenção de produção.httpenfileira respostas externas e captura as solicitações que um plugin envia porctx.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.