샌드박스 플러그인 테스트

이 페이지

@emdash-cms/plugin-test는 샌드박스 플러그인을 빌드하고 로컬 Worker Loader 바인딩을 통해 테스트를 실행합니다. 테스트는 EmDash의 프로덕션 Cloudflare 샌드박스 래퍼와 PluginBridge를 사용하며, @cloudflare/vitest-plugin이 제공하는 로컬 D1 및 Worker Loader 바인딩을 이용합니다.

테스트 대상 동작에 맞는 호스트를 선택하세요.

  • createPluginTestHost()는 샌드박스 전송을 통해 훅 또는 라우트를 직접 호출합니다. 직렬화, capability 강제, 플러그인 스토리지, 라우트 핸들러 로직에 사용하세요.
  • createPluginRuntimeTestHost()는 실제 EmDash 콘텐츠, 플러그인 활성화, 미디어, 댓글, 예약 작업, 플러그인 라우트 액션을 실행합니다. 호스트 액션이 플러그인에 도달함을 증명해야 할 때 사용하세요.

emdash-plugin init으로 만든 프로젝트에는 이 설정이 포함됩니다. 기존 플러그인 프로젝트는 개발 의존성으로 테스트 호스트를 설치할 수 있습니다.

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

프로젝트가 의존성 빌드 스크립트를 제한하면 workerd가 플랫폼 바이너리를 설치하도록 허용하세요. 생성된 pnpm 정책에는 이 항목이 포함됩니다.

allowBuilds:
  workerd: true

Vitest 구성

프로젝트 Vitest 구성에 EmDash 테스트 플러그인을 추가하세요.

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

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

emdashPluginTest()는 Vitest가 시작하기 전에 플러그인 빌드를 실행합니다. 생성된 런타임과 매니페스트를 읽고, 격리된 D1 데이터베이스와 Worker Loader 바인딩을 만들며, Cloudflare 배포와 같은 PluginBridge를 export합니다. Vitest 구성이 플러그인 디렉터리 밖에 있으면 { dir: "./packages/gallery" }를 전달하세요.

샌드박스 전송 테스트

각 테스트 안에서 호스트를 만들고 폐기하세요. 폐기는 플러그인을 중지하고 테스트 바인딩을 재설정합니다.

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()는 입력 값과 선택적 요청 속성을 받습니다. 기본 요청은 빈 헤더와 요청 메타데이터를 가진 플러그인 라우트로의 POST입니다.

직접 호출은 EmDash 라우트 인증, 권한, 토큰 범위, cross-site request forgery(CSRF), 캐시 정책을 행사하지 않습니다. 해당 검사는 런타임 호스트의 host.actions.routes.request()를 사용하세요.

훅과 스토리지 테스트

EmDash에서 받는 이벤트 형태로 훅을 호출하세요. 스토리지와 KV 리더는 브리지를 통해 기록된 상태를 검사합니다.

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

스토리지 호출은 계속 emdash-plugin.jsonc에 선언된 컬렉션을 강제합니다. 콘텐츠, 미디어, 사용자, 이메일, 네트워크 호출은 계속 플러그인의 선언된 capability와 허용 호스트를 강제합니다.

콘텐츠 시드

사이트 콘텐츠를 읽는 라우트나 훅을 호출하기 전에 컬렉션을 만들고 항목을 시드하세요.

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

컬렉션과 항목은 D1에 대한 실제 EmDash 스키마 레지스트리와 콘텐츠 저장소를 사용합니다.

호스트 액션 테스트

결과가 EmDash 오케스트레이션에 의존하면 런타임 호스트를 만드세요. 픽스처는 플러그인 훅을 발화하지 않고 초기 상태를 기록합니다. 액션은 프로덕션 런타임 또는 핸들러 경계를 호출하고, 인스펙터는 플러그인 코드를 호출하지 않고 관측 가능한 상태를 읽습니다.

content:beforeSave 훅이 제목에 [checked]를 붙이는 플러그인의 경우, 다음 테스트는 콘텐츠 저장이 훅에 도달함을 증명합니다.

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

런타임 호스트는 경계별로 API를 그룹화합니다.

  • transport는 전송 수준 검사를 위해 아이솔레이트를 직접 호출합니다.
  • admin은 검증된 Block Kit 페이지와 위젯을 로드하고, 폼을 제출하며, 호스트 증명 로케일 컨텍스트와 함께 프로덕션 라우트 경계를 통해 액션을 호출합니다.
  • fixtures는 훅을 발화하지 않고 사이트, 컬렉션, 필드, 사용자, 바이라인, 택소노미, 콘텐츠, 리다이렉트, 바이너리 미디어, 플러그인 상태를 만듭니다.
  • actions는 콘텐츠 상태 변경, 플러그인 활성화·비활성화, 생성된 설정 업데이트, 미디어 업로드, 공개 댓글 제출, 댓글 중재, 정책 검사된 플러그인 라우트를 실행합니다.
  • inspect는 콘텐츠, 리다이렉트, 바이라인 크레딧, 택소노미 할당, 플러그인 스토리지, KV, 원시 영속 설정 엔벨로프, 플러그인 상태, 예약 작업, 게시 정책 거부, 미디어 메타데이터와 바이트, 댓글, 캡처된 이메일을 읽습니다. 스케줄러 거부권이 관리자 주의를 위해 영속화되었는지 확인하려면 inspect.scheduledPolicyRejections()를 사용하세요.
  • scheduled는 cron 작업과 예약 게시의 유효 시간을 제어한 뒤 프로덕션 유지보수 배치를 한 번 실행합니다.
  • http는 외부 응답을 대기열에 넣고 플러그인이 ctx.http.fetch()를 통해 보내는 요청을 캡처합니다.
  • restart()는 D1, 플러그인 스토리지, 미디어 스토리지, 플러그인 상태를 유지하면서 런타임과 아이솔레이트를 교체합니다.

각 테스트 후 dispose()를 호출하세요. 폐기는 아이솔레이트를 종료하고 모든 바인딩을 재설정하므로 이후 테스트가 이전 호스트의 데이터베이스나 미디어를 관측할 수 없습니다.

설정 액션과 원시 인스펙터를 사용해 생성된 설정 저장이 프로덕션 핸들러에 도달하고 평문을 영속화하지 않음을 증명하세요.

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

호스트를 만들기 전에 테스트 프로세스에 EMDASH_ENCRYPTION_KEY를 설정하세요. 원시 인스펙터는 의도적으로 영속화된 엔벨로프를 반환합니다. 복호화된 ctx.settings 값을 확인하려면 플러그인의 라우트나 훅을 사용하세요.

플러그인을 호출하지 않고 리다이렉트 상태를 설정하려면 host.fixtures.redirect()를 사용하세요. 플러그인이 ctx.redirects를 호출한 후 영속화된 규칙을 단언하려면 host.inspect.redirects()를 사용하세요.

런타임의 스토리지 어댑터와 Worker Loader 브리지를 통해 media:bytes:read를 테스트하려면 바이너리 픽스처를 사용하세요. 다음 픽스처는 의도적으로 더 작은 데이터베이스 크기를 보고하여 스트림 제한이 권위적임을 테스트가 증명할 수 있게 합니다.

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

번역 생성에는 createPluginRuntimeTestHost()를 사용하세요. 직접 전송 호스트는 공유 필드와 귀속을 복사하는 런타임 소유의 번역 수명 주기를 실행하지 않습니다.

가져오는 플러그인 라우트를 호출하기 전에 바이너리 응답을 대기열에 넣으세요.

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()는 응답 바이트를 즉시 소비하고 저장하므로 이후 Worker Loader 요청은 자체 요청 컨텍스트에서 생성된 응답을 받습니다. 같은 URL에 대한 각 예상 호출마다 다른 응답을 대기열에 넣으세요. clear()는 대기열 응답과 캡처된 요청을 제거합니다.

선언된 text, bytes, 또는 form-data 요청을 프로덕션 라우트 파서를 통해 테스트하려면 rawBody를 전달하세요.

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는 문자열, Uint8Array, URLSearchParams, FormData를 포함한 모든 BodyInit을 받습니다. 요청 본문은 버퍼링됩니다. 레거시 JSON 경로에는 body를 사용하세요. 테스트 호스트가 직렬화하고 Content-Type: application/json을 설정합니다.

런타임 호스트는 출시된 작업만 노출합니다. 번역, 게시 정책, Block Kit 상호작용, 택소노미, 리다이렉트, 확장 댓글 관리, 미디어 바이트, 암호화된 설정을 위한 capability별 헬퍼는 해당 capability를 추가하는 릴리스에 속합니다.

테스트 경계

기본 Vitest 구성은 플러그인 개발을 위한 가장 빠른 프로덕션 샌드박스 경로이므로 Worker Loader를 사용합니다. EmDash는 Node.js workerd 러너에 대해서도 동등한 런타임 콘텐츠와 재시작 여정을 실행합니다. 플러그인이 러너에 민감한 동작에 의존하면 별도의 옵트인 Node/workerd 작업을 추가하세요. 생성된 프로젝트는 기본적으로 두 러너를 모두 실행하지 않습니다.

어느 호스트도 EmDash 관리 애플리케이션을 렌더링하지 않으며 Cloudflare의 배포된 CPU, 메모리, 서브요청 제한도 재현하지 않습니다. 브라우저 여정에는 일회용 EmDash 사이트를 사용하고, 제한에 민감한 동작은 Cloudflare 미리보기 또는 스테이징 배포에서 확인하세요.

Block Kit 핸들러에는 host.admin.loadPage() 또는 loadWidget()을 사용해 비공개 라우트, 호스트 증명 로케일 컨텍스트, 응답 검증, Worker Loader 아이솔레이트를 행사하세요. 페이지 상호작용에는 admin.act()와 admin.submit()을 사용하세요.

저장된 항목 확장은 프로덕션 소유권 및 라우트 권한 경계를 사용합니다. 픽스처로 컬렉션, 사용자, 콘텐츠를 만든 뒤 admin.loadEditorPanel(), actEditorPanel(), submitEditorPanel(), 또는 invokeEditorAction()을 호출하세요. 관리 UI 언어에는 locale, 번역된 항목을 선택할 때는 contentLocale을 전달하세요. 이 헬퍼는 아이솔레이트를 호출하기 전에 저장된 항목을 다시 로드하며 저장되지 않은 필드 값을 보내지 않습니다.

관리 헬퍼는 React를 렌더링하지 않습니다. Kumo 렌더링, 확인 대화상자, 키보드 조작, 오른쪽에서 왼쪽 레이아웃을 확인하려면 Block Playground 또는 브라우저 여정을 사용하세요.