Files
page-builder/tests/api/export.spec.ts
T
jaybe 672cca5271
CI / test (push) Failing after 41m13s
CI / pr_and_merge (push) Has been skipped
1차 싱크 완료
2025-11-24 21:32:37 +09:00

1701 lines
53 KiB
TypeScript

import "dotenv/config";
import { describe, it, expect } from "vitest";
import JSZip from "jszip";
import { promises as fs } from "fs";
import path from "path";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate";
import { createFeaturesTemplateBlocks } from "@/app/editor/templates/featuresTemplate";
import { createCtaTemplateBlocks } from "@/app/editor/templates/ctaTemplate";
import { buildStaticHtml } from "@/app/api/export/route";
const BASE_URL = "http://localhost";
// /api/export 라우트 TDD:
// - blocks + projectConfig 를 받아 HTML/CSS/JS 가 포함된 ZIP 을 반환해야 한다.
// - index.html 의 <title> 은 projectConfig.title 을 사용해야 한다.
// - ZIP 안에 builder.css 와 main.js 파일이 포함되어야 한다.
describe("/api/export", () => {
it("POST /api/export 로 블록과 프로젝트 설정을 전송하면 기본 정적 ZIP 파일을 반환해야 한다", async () => {
const blocks: Block[] = [
{
id: "blk_test",
type: "text",
props: {
text: "ZIP 내보내기 테스트",
align: "left",
size: "base",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "내보내기 테스트 페이지",
slug: "export-test",
canvasPreset: "full",
canvasBgColorHex: "#ffffff",
bodyBgColorHex: "#111111",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/zip");
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
const cssEntry = zip.file("builder.css");
const jsEntry = zip.file("main.js");
expect(indexEntry).toBeTruthy();
expect(cssEntry).toBeTruthy();
expect(jsEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("<title>내보내기 테스트 페이지</title>");
expect(html).toContain("ZIP 내보내기 테스트");
// 캔버스 래퍼 배경색
expect(html).toContain("background-color:#ffffff");
// body 스타일: 배경색 + margin/padding 0
expect(html).toContain('style="background-color:#111111;margin:0;padding:0;"');
});
it("canvasPreset / canvasWidthPx / 배경색은 정적 HTML 캔버스 wrapper 및 body 스타일에 반영되어야 한다", () => {
const blocks: Block[] = [];
const createConfig = (partial: Partial<ProjectConfig>): ProjectConfig => ({
title: "캔버스 프리셋 테스트",
slug: "canvas-preset-test",
canvasPreset: "full",
canvasBgColorHex: "#0f172a",
bodyBgColorHex: "#020617",
...partial,
});
const htmlMobile = buildStaticHtml(blocks, createConfig({ canvasPreset: "mobile" }));
expect(htmlMobile).toContain("max-width:390px;");
const htmlTablet = buildStaticHtml(blocks, createConfig({ canvasPreset: "tablet" }));
expect(htmlTablet).toContain("max-width:768px;");
const htmlDesktop = buildStaticHtml(blocks, createConfig({ canvasPreset: "desktop" }));
expect(htmlDesktop).toContain("max-width:1200px;");
const htmlCustom = buildStaticHtml(blocks, createConfig({ canvasPreset: "custom", canvasWidthPx: 1024 }));
expect(htmlCustom).toContain("max-width:1024px;");
const htmlFull = buildStaticHtml(blocks, createConfig({ canvasPreset: "full" }));
// full 프리셋에서 canvasWidthPx 가 없으면 max-width 스타일이 없어야 한다.
expect(htmlFull).not.toContain("max-width:");
// bodyBgColorHex / canvasBgColorHex 도 각각 body 및 캔버스 wrapper 스타일에 반영된다.
const htmlColors = buildStaticHtml(
blocks,
createConfig({ canvasBgColorHex: "#111111", bodyBgColorHex: "#222222" }),
);
expect(htmlColors).toContain("background-color:#111111;");
expect(htmlColors).toContain('style="background-color:#222222;margin:0;padding:0;"');
});
it("projectConfig.headHtml 과 trackingScript 는 index.html 의 head/body 에 그대로 포함되어야 한다", async () => {
const blocks: Block[] = [];
const projectConfig: ProjectConfig = {
title: "헤드/트래킹 테스트",
slug: "head-tracking-test",
canvasPreset: "full",
headHtml: '<meta name="robots" content="noindex" />',
trackingScript: '<script>window.__TEST_TRACKING__ = true;<\/script>',
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
// head 커스텀 HTML
expect(html).toContain('<meta name="robots" content="noindex" />');
// body 하단 추적 스크립트
expect(html).toContain('<script>window.__TEST_TRACKING__ = true;<\/script>');
});
it("index.html head 에는 기본 viewport 메타 태그가 포함되어야 한다", async () => {
const blocks: Block[] = [];
const projectConfig: ProjectConfig = {
title: "뷰포트 메타 테스트",
slug: "viewport-meta-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain(
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
);
});
it("폼 블록과 컨트롤러 필드 블록들은 정적 HTML에서도 폼 요소로 렌더되어야 한다", async () => {
const blocks: Block[] = [
{
id: "form_1",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fields: [],
fieldIds: ["input_name", "select_plan"],
submitButtonId: null,
formWidthMode: "full",
},
} as any,
{
id: "input_name",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
required: true,
},
} as any,
{
id: "select_plan",
type: "formSelect",
props: {
label: "플랜",
formFieldName: "plan",
options: [
{ label: "Basic", value: "basic" },
{ label: "Pro", value: "pro" },
],
required: false,
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "폼 내보내기 테스트",
slug: "form-export-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
// form 요소와 기본 input/select 가 포함되어야 한다.
expect(html).toContain("<form");
expect(html).toContain("name=\"name\"");
expect(html).toContain("name=\"plan\"");
expect(html).toContain("<select");
// required: formInput(name) 은 required, formSelect(plan) 은 required 가 없어야 한다.
expect(html).toMatch(/name=\"name\"[^>]*required/);
expect(html).not.toMatch(/name=\"plan\"[^>]*required/);
});
it("/api/image/:id 기반 이미지 URL은 ZIP 안 images/ 디렉터리로 복사되고 HTML에서 상대 경로로 치환되어야 한다", async () => {
const imageId = `test-image-${Date.now()}`;
const uploadDir = path.join(process.cwd(), "uploads");
await fs.mkdir(uploadDir, { recursive: true });
await fs.writeFile(path.join(uploadDir, imageId), Buffer.from("dummy-image"));
const blocks: Block[] = [
{
id: "blk_image",
type: "image",
props: {
src: `/api/image/${imageId}`,
alt: "테스트 이미지",
align: "center",
widthMode: "auto",
borderRadius: "md",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "이미지 내보내기 테스트",
slug: "image-export-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const buffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(buffer);
const imageEntry = zip.file(`images/${imageId}`);
expect(imageEntry).toBeTruthy();
const imageBytes = await imageEntry!.async("nodebuffer");
expect(imageBytes.length).toBeGreaterThan(0);
const html = await zip.file("index.html")!.async("string");
expect(html).toContain(`src="./images/${imageId}"`);
expect(html).not.toContain(`/api/image/${imageId}`);
});
it("divider 블록은 정적 HTML에서 구분선 요소로 렌더되어야 한다", async () => {
const blocks: Block[] = [
{
id: "divider_1",
type: "divider",
props: {
align: "center",
thickness: "medium",
widthMode: "full",
colorHex: "#123456",
marginYPx: 32,
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "divider 테스트",
slug: "divider-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("<hr");
expect(html).toContain("pb-divider");
// 스타일 검증: medium 두께, 지정 색상, marginYPx 기반 여백이 style 속성에 포함되어야 한다.
expect(html).toContain("border-bottom:2px solid #123456");
expect(html).toContain("margin:32px 0;");
});
it("image 블록은 정적 HTML에서 카드 스타일(배경/너비/둥글기)이 반영되어야 한다", async () => {
const blocks: Block[] = [
{
id: "image_1",
type: "image",
props: {
src: "/images/example.png",
alt: "카드 이미지",
align: "center",
widthMode: "fixed",
widthPx: 480,
borderRadiusPx: 32,
backgroundColorCustom: "#123456",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "image 스타일 테스트",
slug: "image-style-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
// 이미지 태그가 존재해야 한다.
expect(html).toContain('<img src="/images/example.png"');
// 카드 배경색이 wrapper div style 에 반영되어야 한다.
expect(html).toContain("background-color:#123456");
// 고정 너비/둥글기 스타일이 img style 에 반영되어야 한다.
expect(html).toContain("width:480px");
expect(html).toContain("border-radius:32px");
});
it("list 블록은 정적 HTML에서 리스트 요소로 렌더되어야 한다", async () => {
const blocks: Block[] = [
{
id: "list_1",
type: "list",
props: {
items: ["Item 1", "Item 2"],
ordered: false,
align: "left",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "list 테스트",
slug: "list-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("<ul");
expect(html).toContain("<li>Item 1</li>");
expect(html).toContain("<li>Item 2</li>");
});
it("formInput 단독 블록은 입력 필드로 렌더되어야 한다", async () => {
const blocks: Block[] = [
{
id: "input_standalone",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
required: true,
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "formInput 단독 테스트",
slug: "form-input-standalone",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("<input");
expect(html).toContain("name=\"name\"");
expect(html).toContain("이름");
});
it("formSelect 단독 블록은 셀렉트 박스로 렌더되어야 한다", async () => {
const blocks: Block[] = [
{
id: "select_standalone",
type: "formSelect",
props: {
label: "플랜",
formFieldName: "plan",
options: [
{ label: "Basic", value: "basic" },
{ label: "Pro", value: "pro" },
],
required: false,
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "formSelect 단독 테스트",
slug: "form-select-standalone",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("<select");
expect(html).toContain("<option value=\"basic\">Basic</option>");
expect(html).toContain("<option value=\"pro\">Pro</option>");
});
it("formCheckbox 단독 블록은 체크박스 그룹으로 렌더되어야 한다", async () => {
const blocks: Block[] = [
{
id: "checkbox_standalone",
type: "formCheckbox",
props: {
groupLabel: "관심사",
formFieldName: "interests",
options: [
{ label: "뉴스", value: "news" },
{ label: "업데이트", value: "updates" },
],
required: false,
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "formCheckbox 단독 테스트",
slug: "form-checkbox-standalone",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("type=\"checkbox\"");
expect(html).toContain("뉴스");
expect(html).toContain("업데이트");
});
it("formRadio 단독 블록은 라디오 그룹으로 렌더되어야 한다", async () => {
const blocks: Block[] = [
{
id: "radio_standalone",
type: "formRadio",
props: {
groupLabel: "플랜",
formFieldName: "plan",
options: [
{ label: "Basic", value: "basic" },
{ label: "Pro", value: "pro" },
],
required: false,
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "formRadio 단독 테스트",
slug: "form-radio-standalone",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("type=\"radio\"");
expect(html).toContain("Basic");
expect(html).toContain("Pro");
});
describe("템플릿 섹션 정적 export", () => {
function createIdFactory() {
let i = 0;
return () => `tpl_${++i}`;
}
async function exportTemplateHtml(blocks: Block[]): Promise<string> {
const projectConfig: ProjectConfig = {
title: "템플릿 export 테스트",
slug: "template-export-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
return html;
}
it("Hero 템플릿 섹션은 export HTML 에 기본 헤드라인/서브텍스트/버튼 텍스트를 포함해야 한다", async () => {
const sectionId = "hero_section_export";
const { blocks } = createHeroTemplateBlocks({ sectionId, createId: createIdFactory() });
const html = await exportTemplateHtml(blocks as Block[]);
expect(html).toContain("Hero 제목을 여기에 입력하세요");
expect(html).toContain("제품이나 서비스를 한 문장으로 설명하는 서브텍스트입니다.");
expect(html).toContain("지금 시작하기");
});
it("Features 템플릿 섹션은 export HTML 에 3개의 Feature 제목과 설명 텍스트를 포함해야 한다", async () => {
const sectionId = "features_section_export";
const { blocks } = createFeaturesTemplateBlocks({ sectionId, createId: createIdFactory() });
const html = await exportTemplateHtml(blocks as Block[]);
expect(html).toContain("Feature 1 제목");
expect(html).toContain("Feature 2 제목");
expect(html).toContain("Feature 3 제목");
const descText = "해당 기능을 간단히 설명하는 텍스트입니다.";
const occurrences = html.split(descText).length - 1;
expect(occurrences).toBeGreaterThanOrEqual(3);
});
it("CTA 템플릿 섹션은 export HTML 에 CTA 본문 텍스트와 버튼 라벨을 포함해야 한다", async () => {
const sectionId = "cta_section_export";
const { blocks } = createCtaTemplateBlocks({ sectionId, createId: createIdFactory() });
const html = await exportTemplateHtml(blocks as Block[]);
expect(html).toContain("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.");
expect(html).toContain("CTA 버튼");
});
});
describe("스타일 테스트", () => {
it("text 블록은 pb 텍스트 스타일 클래스를 포함해야 한다", async () => {
const blocks: Block[] = [
{
id: "text_styled",
type: "text",
props: {
text: "스타일 테스트",
align: "center",
size: "base",
fontSizeMode: "scale",
fontSizeScale: "lg",
lineHeightMode: "scale",
lineHeightScale: "relaxed",
fontWeightMode: "scale",
fontWeightScale: "semibold",
colorMode: "palette",
colorPalette: "strong",
underline: true,
italic: true,
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "텍스트 스타일 테스트",
slug: "text-style-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("스타일 테스트");
expect(html).toContain("pb-text-center");
expect(html).toContain("pb-text-lg");
expect(html).toContain("pb-leading-relaxed");
expect(html).toContain("pb-font-semibold");
expect(html).toContain("pb-text-color-strong");
expect(html).toContain("pb-underline");
expect(html).toContain("pb-italic");
});
it("기본 텍스트 블록은 프리뷰처럼 흰 글자와 줄바꿈 유지 스타일을 사용해야 한다", async () => {
const blocks: Block[] = [
{
id: "text_default",
type: "text",
props: {
text: "첫 줄\n둘째 줄",
align: "left",
size: "base",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "텍스트 기본 스타일 테스트",
slug: "text-default-style-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("첫 줄");
expect(html).toContain("둘째 줄");
expect(html).toContain("pb-text-color-strong");
expect(html).toContain("pb-whitespace-pre-wrap");
});
it("custom 색상을 가진 텍스트 블록은 정적 HTML에서도 같은 색상을 사용해야 한다", async () => {
const blocks: Block[] = [
{
id: "text_custom_color",
type: "text",
props: {
text: "커스텀 색상 텍스트",
align: "left",
size: "base",
colorMode: "custom",
colorCustom: "#ff0000",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "텍스트 색상 커스텀 테스트",
slug: "text-custom-color-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("커스텀 색상 텍스트");
expect(html).toContain("color:#ff0000");
});
it("backgroundColorCustom 이 지정된 텍스트 블록은 정적 HTML에서 같은 배경색을 사용해야 한다", async () => {
const blocks: Block[] = [
{
id: "text_bg_custom",
type: "text",
props: {
text: "배경색 있는 텍스트",
align: "left",
size: "base",
backgroundColorCustom: "#123456",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "텍스트 배경색 테스트",
slug: "text-bg-color-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("배경색 있는 텍스트");
expect(html).toContain("background-color:#123456");
});
it("backgroundColorCustom 이 지정된 리스트 블록은 정적 HTML에서 리스트 컨테이너 배경색을 반영해야 한다", async () => {
const blocks: Block[] = [
{
id: "list_bg_custom",
type: "list",
props: {
items: ["아이템 1"],
ordered: false,
align: "left",
backgroundColorCustom: "#00ff88",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "리스트 배경색 테스트",
slug: "list-bg-color-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("아이템 1");
expect(html).toContain("background-color:#00ff88");
});
it("backgroundColorCustom 이 지정된 폼 블록은 정적 HTML에서 form 요소 배경색을 반영해야 한다", async () => {
const blocks: Block[] = [
{
id: "form_bg_custom",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fields: [],
fieldIds: [],
formWidthMode: "auto",
backgroundColorCustom: "#111111",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "폼 배경색 테스트",
slug: "form-bg-color-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("<form");
expect(html).toContain("background-color:#111111");
});
it("backgroundColorCustom 이 지정된 섹션 블록은 정적 HTML에서 섹션 요소 배경색을 인라인 스타일로 반영해야 한다", async () => {
const blocks: Block[] = [
{
id: "sec_1",
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [{ id: "sec_1_col_1", span: 12 }],
backgroundColorCustom: "#123456",
},
} as any,
{
id: "sec_1_text",
type: "text",
sectionId: "sec_1",
columnId: "sec_1_col_1",
props: {
text: "섹션 안 텍스트",
align: "left",
size: "base",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "섹션 배경색 테스트",
slug: "section-bg-color-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("섹션 안 텍스트");
expect(html).toContain("class=\"pb-section");
expect(html).toContain("background-color:#123456");
});
it("formInput 블록의 textColorCustom 은 정적 HTML에서 input 텍스트 색상 스타일로 반영되어야 한다", async () => {
const blocks: Block[] = [
{
id: "form_input_color",
type: "formInput",
props: {
label: "이메일",
formFieldName: "email",
required: true,
textColorCustom: "#123456",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "폼 입력 텍스트 색상 테스트",
slug: "form-input-text-color-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("name=\"email\"");
expect(html).toContain("color:#123456");
});
it("formSelect 블록의 textColorCustom 은 정적 HTML에서 select 텍스트 색상 스타일로 반영되어야 한다", async () => {
const blocks: Block[] = [
{
id: "form_select_color_export",
type: "formSelect",
props: {
label: "플랜",
formFieldName: "plan",
options: [
{ label: "Basic", value: "basic" },
{ label: "Pro", value: "pro" },
],
textColorCustom: "#00ff88",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "폼 셀렉트 텍스트 색상 테스트",
slug: "form-select-text-color-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("name=\"plan\"");
expect(html).toContain("color:#00ff88");
});
it("formCheckbox 블록의 textColorCustom 은 정적 HTML에서 그룹/옵션 텍스트 색상 스타일로 반영되어야 한다", async () => {
const blocks: Block[] = [
{
id: "form_checkbox_color_export",
type: "formCheckbox",
props: {
groupLabel: "기능",
formFieldName: "features",
options: [{ label: "옵션 1", value: "opt1" }],
textColorCustom: "#8800ff",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "폼 체크박스 텍스트 색상 테스트",
slug: "form-checkbox-text-color-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("name=\"features\"");
expect(html).toContain("color:#8800ff");
});
it("formRadio 블록의 textColorCustom 은 정적 HTML에서 그룹/옵션 텍스트 색상 스타일로 반영되어야 한다", async () => {
const blocks: Block[] = [
{
id: "form_radio_color_export",
type: "formRadio",
props: {
groupLabel: "플랜",
formFieldName: "plan",
options: [{ label: "A 플랜", value: "a" }],
textColorCustom: "#ff9900",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "폼 라디오 텍스트 색상 테스트",
slug: "form-radio-text-color-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("name=\"plan\"");
expect(html).toContain("color:#ff9900");
});
it("formInput 블록은 스타일 속성으로 필드 배경/보더/패딩/너비/둥글기를 반영해야 한다", async () => {
const blocks: Block[] = [
{
id: "form_input_styles_export",
type: "formInput",
props: {
label: "이메일",
formFieldName: "email",
required: true,
widthMode: "fixed",
widthPx: 320,
paddingX: 16,
paddingY: 8,
textColorCustom: "#112233",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
borderRadius: "lg",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "폼 입력 스타일 테스트",
slug: "form-input-style-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("name=\"email\"");
expect(html).toContain("width:320px");
expect(html).toContain("padding-left:16px");
expect(html).toContain("padding-right:16px");
expect(html).toContain("padding-top:8px");
expect(html).toContain("padding-bottom:8px");
expect(html).toContain("background-color:#123456");
expect(html).toContain("border-color:#ff0000");
expect(html).toContain("border-radius:6px");
});
it("formSelect 블록은 스타일 속성으로 셀렉트 배경/보더/패딩/너비/둥글기를 반영해야 한다", async () => {
const blocks: Block[] = [
{
id: "form_select_styles_export",
type: "formSelect",
props: {
label: "카테고리",
formFieldName: "category",
options: [
{ label: "옵션 A", value: "a" },
{ label: "옵션 B", value: "b" },
],
widthMode: "fixed",
widthPx: 240,
paddingX: 16,
paddingY: 8,
textColorCustom: "#00ff00",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
borderRadius: "lg",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "폼 셀렉트 스타일 테스트",
slug: "form-select-style-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("name=\"category\"");
expect(html).toContain("width:240px");
expect(html).toContain("padding-left:16px");
expect(html).toContain("padding-right:16px");
expect(html).toContain("padding-top:8px");
expect(html).toContain("padding-bottom:8px");
expect(html).toContain("background-color:#123456");
expect(html).toContain("border-color:#ff0000");
expect(html).toContain("border-radius:6px");
});
it("formCheckbox 블록은 스타일 속성으로 옵션 컨테이너 배경/보더/패딩/둥글기를 반영해야 한다", async () => {
const blocks: Block[] = [
{
id: "form_checkbox_styles_export",
type: "formCheckbox",
props: {
groupLabel: "기능",
formFieldName: "features",
options: [{ label: "옵션 1", value: "opt1" }],
paddingX: 8,
paddingY: 4,
textColorCustom: "#ffffff",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
borderRadius: "lg",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "폼 체크박스 스타일 테스트",
slug: "form-checkbox-style-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("name=\"features\"");
expect(html).toContain("padding-left:8px");
expect(html).toContain("padding-right:8px");
expect(html).toContain("padding-top:4px");
expect(html).toContain("padding-bottom:4px");
expect(html).toContain("background-color:#123456");
expect(html).toContain("border-color:#ff0000");
expect(html).toContain("border-radius:6px");
});
it("formRadio 블록은 스타일 속성으로 옵션 컨테이너 배경/보더/패딩/둥글기를 반영해야 한다", async () => {
const blocks: Block[] = [
{
id: "form_radio_styles_export",
type: "formRadio",
props: {
groupLabel: "플랜",
formFieldName: "plan",
options: [{ label: "플랜 A", value: "a" }],
paddingX: 8,
paddingY: 4,
textColorCustom: "#ffffff",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
borderRadius: "lg",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "폼 라디오 스타일 테스트",
slug: "form-radio-style-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("name=\"plan\"");
expect(html).toContain("padding-left:8px");
expect(html).toContain("padding-right:8px");
expect(html).toContain("padding-top:4px");
expect(html).toContain("padding-bottom:4px");
expect(html).toContain("background-color:#123456");
expect(html).toContain("border-color:#ff0000");
expect(html).toContain("border-radius:6px");
});
it("버튼 블록은 버튼 스타일 클래스를 포함해야 한다", async () => {
const blocks: Block[] = [
{
id: "button_styled",
type: "button",
props: {
label: "클릭",
href: "#",
align: "center",
size: "md",
variant: "solid",
colorPalette: "primary",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "버튼 스타일 테스트",
slug: "button-style-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("클릭");
expect(html).toContain("pb-text-center");
expect(html).toContain("pb-btn-base");
expect(html).toContain("pb-btn-size-md");
expect(html).toContain("pb-btn-variant-solid-primary");
});
it("버튼 블록의 커스텀 스타일은 style 속성으로 반영되어야 한다", async () => {
const blocks: Block[] = [
{
id: "button_custom_styles",
type: "button",
props: {
label: "스타일 버튼",
href: "#",
align: "left",
widthMode: "fixed",
widthPx: 240,
paddingX: 16,
paddingY: 8,
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
textColorCustom: "#00ff00",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "버튼 커스텀 스타일 테스트",
slug: "button-custom-style-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
// widthMode=fixed, widthPx, paddingX/paddingY, fillColorCustom, strokeColorCustom, textColorCustom 이 style 속성에 포함되어야 한다.
expect(html).toContain("width:240px");
expect(html).toContain("padding-left:16px");
expect(html).toContain("padding-right:16px");
expect(html).toContain("padding-top:8px");
expect(html).toContain("padding-bottom:8px");
expect(html).toContain("background-color:#123456");
expect(html).toContain("border-color:#ff0000");
expect(html).toContain("color:#00ff00");
});
it("section 블록은 배경/패딩 토큰을 pb-section 클래스 조합으로 반영해야 한다", async () => {
const blocks: Block[] = [
{
id: "sec_1",
type: "section",
props: {
background: "primary",
paddingY: "lg",
columns: [
{
id: "sec_1_col_1",
span: 12,
},
],
maxWidthMode: "normal",
gapX: "md",
alignItems: "top",
},
} as any,
{
id: "sec_1_text",
type: "text",
props: {
text: "섹션 텍스트",
align: "left",
size: "base",
},
sectionId: "sec_1",
columnId: "sec_1_col_1",
} as any,
];
const projectConfig: ProjectConfig = {
title: "섹션 스타일 테스트",
slug: "section-style-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("pb-section");
expect(html).toContain("pb-section-bg-primary");
expect(html).toContain("pb-section-py-lg");
});
it("builder.css 는 리스트/섹션 유틸리티 클래스를 포함해야 한다", async () => {
const blocks: Block[] = [];
const projectConfig: ProjectConfig = {
title: "CSS 유틸리티 테스트",
slug: "css-utility-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const cssEntry = zip.file("builder.css");
expect(cssEntry).toBeTruthy();
const css = await cssEntry!.async("string");
expect(css).toContain(".pb-section-bg-default");
expect(css).toContain(".pb-section-py-md");
expect(css).toContain(".pb-list");
expect(css).toContain(".pb-whitespace-pre-wrap");
expect(css).toContain("color: var(--pb-color-text-strong)");
});
});
});