1211 lines
55 KiB
TypeScript
1211 lines
55 KiB
TypeScript
import { test, expect } from "@playwright/test";
|
|
|
|
// 모든 블록 타입의 스타일이 builder.css 기반 pb-* 토큰과 일관되게 적용되는지
|
|
// 에디터 → 프리뷰 → 퍼블릭(/p/[slug]) 컨텍스트를 가로질러 검증하는 회귀 스위트.
|
|
// - 텍스트, 버튼, 리스트, 이미지, 비디오, 폼 입력 필드까지 최소 1개씩 포함한 프로젝트를 구성한다.
|
|
// - 프리뷰에서 각 블록의 computed style 을 스냅샷으로 수집한 뒤,
|
|
// 동일한 slug 의 퍼블릭 페이지(/p/[slug])에서 다시 측정해 값이 일치하는지 비교한다.
|
|
// - 이 테스트는 builder.css 가 단일 진실 소스로 동작하는지(컨텍스트 간 시각 결과가 같은지)를 보장한다.
|
|
|
|
// 인증 가드를 통과시키기 위해 /api/auth/me 를 항상 200 으로 목 처리한다.
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.route("**/api/auth/me", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ id: "user-style-regression", email: "style-regression@example.com", tokenVersion: 1 }),
|
|
});
|
|
});
|
|
});
|
|
|
|
function parsePx(value: string): number | null {
|
|
const n = parseFloat(value || "");
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
|
|
async function ensureAuthenticated(page: any, request: any): Promise<string> {
|
|
const now = Date.now();
|
|
const email = `style-regression-${now}@example.com`;
|
|
const password = "style-regression-password";
|
|
|
|
const signupRes = await request.post("/api/auth/signup", {
|
|
headers: { "Content-Type": "application/json" },
|
|
data: { email, password },
|
|
});
|
|
|
|
const status = signupRes.status();
|
|
if (status !== 201) {
|
|
// 디버깅용: CI 등에서 500 이 떨어질 때 응답 바디를 함께 로그로 출력해 원인을 파악할 수 있게 한다.
|
|
console.log("[block-styles-regression] signup error status=", status);
|
|
try {
|
|
const bodyText = await signupRes.text();
|
|
console.log("[block-styles-regression] signup error body=", bodyText);
|
|
} catch (e) {
|
|
console.log("[block-styles-regression] signup error: body read failed", e);
|
|
}
|
|
}
|
|
|
|
expect(status).toBe(201);
|
|
|
|
const setCookieHeader = signupRes.headers()["set-cookie"] as string | string[] | null;
|
|
expect(setCookieHeader).toBeTruthy();
|
|
|
|
const cookieHeaderString = Array.isArray(setCookieHeader)
|
|
? setCookieHeader.join("; ")
|
|
: (setCookieHeader as string);
|
|
|
|
const match = cookieHeaderString.match(/pb_access=([^;]+)/);
|
|
expect(match).not.toBeNull();
|
|
|
|
const accessToken = match![1];
|
|
|
|
await page.context().addCookies([
|
|
{
|
|
name: "pb_access",
|
|
value: accessToken,
|
|
domain: "localhost",
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: false,
|
|
sameSite: "Lax",
|
|
},
|
|
]);
|
|
|
|
return accessToken;
|
|
}
|
|
|
|
async function patchProjectStatusForCurrentUser(
|
|
request: import("@playwright/test").APIRequestContext,
|
|
accessToken: string,
|
|
slug: string,
|
|
status: "DRAFT" | "PUBLISHED" | "ARCHIVED",
|
|
) {
|
|
const res = await request.patch(`/api/projects/${slug}`, {
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Cookie: `pb_access=${accessToken}`,
|
|
},
|
|
data: { status },
|
|
});
|
|
|
|
const code = res.status();
|
|
if (code !== 200) {
|
|
console.log("[block-styles-regression] patchProjectStatus error status=", code);
|
|
try {
|
|
const bodyText = await res.text();
|
|
console.log("[block-styles-regression] patchProjectStatus error body=", bodyText);
|
|
} catch (e) {
|
|
console.log("[block-styles-regression] patchProjectStatus error: body read failed", e);
|
|
}
|
|
}
|
|
|
|
expect(code).toBe(200);
|
|
}
|
|
|
|
test("텍스트/버튼/리스트/이미지/비디오/폼 입력 필드 스타일이 프리뷰와 퍼블릭 페이지에서 동일해야 한다", async ({ page, request }) => {
|
|
const now = Date.now();
|
|
const slug = `style-regression-${now}`;
|
|
|
|
// 1) 에디터에서 프로젝트 slug 와 기본 블록들을 구성한다.
|
|
const accessToken = await ensureAuthenticated(page, request);
|
|
await page.goto("/editor");
|
|
|
|
const sidebar = page.getByTestId("properties-sidebar");
|
|
await sidebar.getByLabel("Project title").fill("스타일 회귀 테스트");
|
|
await sidebar.getByLabel("Project slug").fill(slug);
|
|
|
|
const canvas = page.getByTestId("editor-canvas");
|
|
|
|
// 텍스트 블록 추가 및 스타일 설정
|
|
await page.getByRole("button", { name: "Text" }).first().click();
|
|
await canvas.getByText("New text").first().click({ force: true });
|
|
const textContentInput = page.getByRole("textbox", { name: "Selected text block content" });
|
|
await textContentInput.fill("스타일 회귀 텍스트 블록");
|
|
await page.getByRole("combobox", { name: "Alignment" }).selectOption("center");
|
|
await page.getByRole("combobox", { name: "Font size" }).selectOption("lg");
|
|
|
|
// 버튼 블록 추가 및 스타일 설정
|
|
await page.getByRole("button", { name: "Button" }).click();
|
|
const buttonBlock = canvas.getByRole("button", { name: "Button" }).first();
|
|
await buttonBlock.click({ force: true });
|
|
await page.getByRole("combobox", { name: "Button alignment" }).selectOption("center");
|
|
// 버튼 크기 프리셋: NumericPropertyControl 이 제공하는 id (xs/sm/base/lg/xl/2xl/3xl) 중 하나를 사용한다.
|
|
await page.getByRole("combobox", { name: "Button style" }).selectOption("solid");
|
|
|
|
// 리스트 블록 추가
|
|
await page.getByRole("button", { name: "List" }).click();
|
|
const listTextarea = page.getByLabel("List items");
|
|
await listTextarea.fill("List item 1\nList item 2");
|
|
|
|
// 이미지 블록 추가 (간단히 /api/image 경로가 아닌 placeholder 이미지 URL 사용)
|
|
await page.getByRole("button", { name: "Image" }).click();
|
|
const imageUrlInput = page.getByLabel("Image URL");
|
|
await imageUrlInput.fill("https://via.placeholder.com/320x180.png?text=pb-image");
|
|
const imageAltInput = page.getByLabel("Alt text");
|
|
await imageAltInput.fill("스타일 회귀 이미지");
|
|
const imageWidthModeSelect = sidebar.getByLabel("Width mode");
|
|
await imageWidthModeSelect.selectOption("fixed");
|
|
const imageWidthSlider = sidebar.getByLabel("Fixed width 커스텀");
|
|
await imageWidthSlider.fill("320");
|
|
|
|
// 비디오 블록 추가 (YouTube URL 사용)
|
|
await page.getByRole("button", { name: "Video" }).click();
|
|
const videoUrlInput = page.getByLabel("Video URL");
|
|
await videoUrlInput.fill("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
|
|
|
|
// 폼 입력 블록 추가 (플로팅 라벨은 아닌 기본 visible 레이아웃)
|
|
await page.getByRole("button", { name: "Input field" }).click();
|
|
// FormInputPropertiesPanel 에서는 "Field label" 이라는 레이블을 사용한다.
|
|
const formLabelInput = page.getByRole("textbox", { name: "Field label" });
|
|
await formLabelInput.fill("이메일 입력 필드");
|
|
|
|
// 2) 헤더의 프리뷰 링크를 통해 현재 상태를 프리뷰로 열어 스타일을 측정한다.
|
|
await page.getByRole("link", { name: "Open preview" }).click();
|
|
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
|
|
|
// 텍스트 블록
|
|
const previewText = page.getByText("스타일 회귀 텍스트 블록").first();
|
|
const previewTextStyles = await previewText.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
textAlign: s.textAlign,
|
|
fontSize: s.fontSize,
|
|
color: s.color,
|
|
lineHeight: s.lineHeight,
|
|
letterSpacing: s.letterSpacing,
|
|
fontWeight: s.fontWeight,
|
|
textDecorationLine: s.textDecorationLine,
|
|
fontStyle: s.fontStyle,
|
|
backgroundColor: s.backgroundColor,
|
|
};
|
|
});
|
|
|
|
// 버튼 (프리뷰에서는 <a> 로 렌더링됨)
|
|
const previewButton = page.getByRole("link", { name: "Button" }).first();
|
|
const previewButtonStyles = await previewButton.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
fontSize: s.fontSize,
|
|
color: s.color,
|
|
backgroundColor: s.backgroundColor,
|
|
borderRadius: s.borderRadius,
|
|
};
|
|
});
|
|
|
|
// 리스트 첫 번째 아이템
|
|
const previewFirstLi = page.locator("li").first();
|
|
const previewListStyles = await previewFirstLi.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLLIElement);
|
|
return {
|
|
textAlign: s.textAlign,
|
|
fontSize: s.fontSize,
|
|
color: s.color,
|
|
marginBottom: s.marginBottom,
|
|
listStyleType: s.listStyleType,
|
|
};
|
|
});
|
|
|
|
// 이미지
|
|
const previewImage = page.getByRole("img", { name: "스타일 회귀 이미지" }).first();
|
|
const previewImageStyles = await previewImage.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLImageElement);
|
|
return {
|
|
width: s.width,
|
|
borderRadius: s.borderRadius,
|
|
};
|
|
});
|
|
|
|
// 비디오 wrapper (iframe 상위 pb-video-wrapper)가 프리뷰에도 DOM 상에 존재하는지만 확인한다.
|
|
const previewVideoWrapper = page.locator(".pb-video-wrapper").first();
|
|
await expect(previewVideoWrapper).toBeAttached();
|
|
|
|
// 폼 입력 필드 (라벨 + input)
|
|
const previewInput = page.getByRole("textbox", { name: "이메일 입력 필드" });
|
|
const previewInputStyles = await previewInput.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLInputElement);
|
|
return {
|
|
textAlign: s.textAlign,
|
|
fontSize: s.fontSize,
|
|
color: s.color,
|
|
backgroundColor: s.backgroundColor,
|
|
borderRadius: s.borderRadius,
|
|
};
|
|
});
|
|
|
|
// 3) 프리뷰 측정을 마친 뒤, 에디터로 돌아가 프로젝트를 서버에 저장해 /p/[slug] 페이지에서도 동일 구성을 볼 수 있게 한다.
|
|
await page.getByRole("link", { name: "Back to editor" }).click();
|
|
await expect(page).toHaveURL(/\/editor/);
|
|
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible({ timeout: 15000 });
|
|
|
|
await page.getByRole("button", { name: "Menu" }).click();
|
|
await page.getByRole("button", { name: "Save / load project" }).click();
|
|
const projectSlugModalInput = page.getByLabel("Project address (e.g. my-landing)");
|
|
await projectSlugModalInput.fill(slug);
|
|
await page.getByRole("button", { name: "Save (local + server)" }).click();
|
|
|
|
await expect(page).toHaveURL(/\/_?projects/);
|
|
await patchProjectStatusForCurrentUser(request, accessToken, slug, "PUBLISHED");
|
|
await page.goto(`/p/${slug}`);
|
|
|
|
// 4) 동일한 slug 의 퍼블릭 페이지(/p/[slug])로 이동해 같은 요소들의 스타일을 다시 측정한다.
|
|
|
|
// 텍스트 블록
|
|
const publicText = page.getByText("스타일 회귀 텍스트 블록").first();
|
|
const publicTextStyles = await publicText.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
textAlign: s.textAlign,
|
|
fontSize: s.fontSize,
|
|
color: s.color,
|
|
lineHeight: s.lineHeight,
|
|
letterSpacing: s.letterSpacing,
|
|
fontWeight: s.fontWeight,
|
|
textDecorationLine: s.textDecorationLine,
|
|
fontStyle: s.fontStyle,
|
|
backgroundColor: s.backgroundColor,
|
|
};
|
|
});
|
|
|
|
expect(publicTextStyles.textAlign).toBe(previewTextStyles.textAlign);
|
|
expect(publicTextStyles.fontSize).toBe(previewTextStyles.fontSize);
|
|
expect(publicTextStyles.color).toBe(previewTextStyles.color);
|
|
expect(publicTextStyles.lineHeight).toBe(previewTextStyles.lineHeight);
|
|
expect(publicTextStyles.letterSpacing).toBe(previewTextStyles.letterSpacing);
|
|
expect(publicTextStyles.fontWeight).toBe(previewTextStyles.fontWeight);
|
|
expect(publicTextStyles.textDecorationLine).toBe(previewTextStyles.textDecorationLine);
|
|
expect(publicTextStyles.fontStyle).toBe(previewTextStyles.fontStyle);
|
|
expect(publicTextStyles.backgroundColor).toBe(previewTextStyles.backgroundColor);
|
|
|
|
// 버튼: 프리뷰와 동일하게 role=link, name="버튼" 기준으로 선택한다.
|
|
const publicButton = page.getByRole("link", { name: "Button" }).first();
|
|
const publicButtonStyles = await publicButton.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
fontSize: s.fontSize,
|
|
color: s.color,
|
|
backgroundColor: s.backgroundColor,
|
|
borderRadius: s.borderRadius,
|
|
};
|
|
});
|
|
|
|
const previewButtonFont = parsePx(previewButtonStyles.fontSize);
|
|
const publicButtonFont = parsePx(publicButtonStyles.fontSize);
|
|
if (previewButtonFont !== null && publicButtonFont !== null) {
|
|
// 브라우저/렌더링 환경에 따른 소수점/루트 폰트 크기 차이만 허용하고, ±2px 이내에서만 허용한다.
|
|
expect(Math.abs(publicButtonFont - previewButtonFont)).toBeLessThanOrEqual(2);
|
|
} else {
|
|
expect(publicButtonStyles.fontSize).toBe(previewButtonStyles.fontSize);
|
|
}
|
|
expect(publicButtonStyles.color).toBe(previewButtonStyles.color);
|
|
expect(publicButtonStyles.backgroundColor).toBe(previewButtonStyles.backgroundColor);
|
|
const previewBorderRadiusPx = parsePx(previewButtonStyles.borderRadius);
|
|
const publicBorderRadiusPx = parsePx(publicButtonStyles.borderRadius);
|
|
if (previewBorderRadiusPx !== null && publicBorderRadiusPx !== null) {
|
|
expect(Math.abs(publicBorderRadiusPx - previewBorderRadiusPx)).toBeLessThanOrEqual(2);
|
|
} else {
|
|
expect(publicButtonStyles.borderRadius).toBe(previewButtonStyles.borderRadius);
|
|
}
|
|
|
|
// 리스트 첫 번째 아이템
|
|
const publicFirstLi = page.locator("li").first();
|
|
const publicListStyles = await publicFirstLi.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLLIElement);
|
|
return {
|
|
textAlign: s.textAlign,
|
|
fontSize: s.fontSize,
|
|
color: s.color,
|
|
marginBottom: s.marginBottom,
|
|
listStyleType: s.listStyleType,
|
|
};
|
|
});
|
|
|
|
expect(publicListStyles.textAlign).toBe(previewListStyles.textAlign);
|
|
expect(publicListStyles.fontSize).toBe(previewListStyles.fontSize);
|
|
expect(publicListStyles.color).toBe(previewListStyles.color);
|
|
expect(publicListStyles.listStyleType).toBe(previewListStyles.listStyleType);
|
|
|
|
const previewGap = parsePx(previewListStyles.marginBottom);
|
|
const publicGap = parsePx(publicListStyles.marginBottom);
|
|
if (previewGap !== null && publicGap !== null) {
|
|
// 브라우저/렌더링 환경에 따른 소수점 차이만 허용하고, 합리적인 오차 범위(±2px) 안에서만 허용한다.
|
|
expect(Math.abs(previewGap - publicGap)).toBeLessThanOrEqual(2);
|
|
}
|
|
|
|
// 이미지
|
|
const publicImage = page.getByRole("img", { name: "스타일 회귀 이미지" }).first();
|
|
const publicImageStyles = await publicImage.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLImageElement);
|
|
return {
|
|
borderRadius: s.borderRadius,
|
|
};
|
|
});
|
|
|
|
expect(publicImageStyles.borderRadius).toBe(previewImageStyles.borderRadius);
|
|
|
|
// 비디오 wrapper 가 퍼블릭 페이지에도 DOM 상에 존재하는지만 확인한다.
|
|
const publicVideoWrapper = page.locator(".pb-video-wrapper").first();
|
|
await expect(publicVideoWrapper).toBeAttached();
|
|
|
|
// 폼 입력 필드
|
|
const publicInput = page.getByRole("textbox", { name: "이메일 입력 필드" });
|
|
const publicInputStyles = await publicInput.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLInputElement);
|
|
return {
|
|
textAlign: s.textAlign,
|
|
fontSize: s.fontSize,
|
|
color: s.color,
|
|
backgroundColor: s.backgroundColor,
|
|
borderRadius: s.borderRadius,
|
|
};
|
|
});
|
|
|
|
const normalizeAlign = (value: string) =>
|
|
value === "start" ? "left" : value === "end" ? "right" : value;
|
|
expect(normalizeAlign(publicInputStyles.textAlign)).toBe(
|
|
normalizeAlign(previewInputStyles.textAlign),
|
|
);
|
|
expect(publicInputStyles.fontSize).toBe(previewInputStyles.fontSize);
|
|
expect(publicInputStyles.color).toBe(previewInputStyles.color);
|
|
expect(publicInputStyles.backgroundColor).toBe(previewInputStyles.backgroundColor);
|
|
expect(publicInputStyles.borderRadius).toBe(previewInputStyles.borderRadius);
|
|
});
|
|
|
|
test("버튼 너비/패딩/색상/둥글기 스타일이 프리뷰와 퍼블릭 페이지에서 동일해야 한다", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const now = Date.now();
|
|
const slug = `button-layout-style-regression-${now}`;
|
|
|
|
const accessToken = await ensureAuthenticated(page, request);
|
|
await page.goto("/editor");
|
|
|
|
const sidebar = page.getByTestId("properties-sidebar");
|
|
await sidebar.getByLabel("Project title").fill("버튼 레이아웃 스타일 회귀 테스트");
|
|
await sidebar.getByLabel("Project slug").fill(slug);
|
|
|
|
const canvas = page.getByTestId("editor-canvas");
|
|
|
|
// 버튼 블록 추가 및 선택
|
|
await page.getByRole("button", { name: "Button" }).click();
|
|
const buttonBlock = canvas.getByRole("button", { name: "Button" }).first();
|
|
await buttonBlock.click({ force: true });
|
|
|
|
// 버튼 레이아웃/패딩/둥글기 설정
|
|
await sidebar.getByLabel("Button width mode").selectOption("fixed");
|
|
await sidebar.getByLabel("Button fixed width 커스텀").fill("260");
|
|
|
|
await sidebar.getByLabel("Horizontal padding 커스텀").fill("24");
|
|
await sidebar.getByLabel("Vertical padding 커스텀").fill("12");
|
|
|
|
// 모서리 둥글기 최댓값(4) → borderRadius="full" 토큰
|
|
await sidebar.getByLabel("Border radius 슬라이더").fill("4");
|
|
|
|
// 프리뷰에서 버튼 스타일 측정
|
|
await page.getByRole("link", { name: "Open preview" }).click();
|
|
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
|
|
|
const previewButton = page.getByRole("link", { name: "Button" }).first();
|
|
const previewButtonStyles = await previewButton.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
width: s.width,
|
|
paddingLeft: s.paddingLeft,
|
|
paddingRight: s.paddingRight,
|
|
paddingTop: s.paddingTop,
|
|
paddingBottom: s.paddingBottom,
|
|
backgroundColor: s.backgroundColor,
|
|
borderColor: s.borderColor,
|
|
borderRadius: s.borderRadius,
|
|
fontSize: s.fontSize,
|
|
color: s.color,
|
|
};
|
|
});
|
|
|
|
// 프로젝트 저장 후 퍼블릭 페이지로 이동
|
|
await page.getByRole("link", { name: "Back to editor" }).click();
|
|
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Menu" }).click();
|
|
await page.getByRole("button", { name: "Save / load project" }).click();
|
|
const projectSlugModalInput = page.getByLabel("Project address (e.g. my-landing)");
|
|
await projectSlugModalInput.fill(slug);
|
|
await page.getByRole("button", { name: "Save (local + server)" }).click();
|
|
|
|
await page.waitForURL(/\/projects/);
|
|
await patchProjectStatusForCurrentUser(request, accessToken, slug, "PUBLISHED");
|
|
await page.goto(`/p/${slug}`);
|
|
|
|
const publicButton = page.getByRole("link", { name: "Button" }).first();
|
|
const publicButtonStyles = await publicButton.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
width: s.width,
|
|
paddingLeft: s.paddingLeft,
|
|
paddingRight: s.paddingRight,
|
|
paddingTop: s.paddingTop,
|
|
paddingBottom: s.paddingBottom,
|
|
backgroundColor: s.backgroundColor,
|
|
borderColor: s.borderColor,
|
|
borderRadius: s.borderRadius,
|
|
fontSize: s.fontSize,
|
|
color: s.color,
|
|
};
|
|
});
|
|
|
|
// width / padding 은 px 기준으로 ±2px 이내여야 한다.
|
|
const previewWidth = parsePx(previewButtonStyles.width)!;
|
|
const publicWidth = parsePx(publicButtonStyles.width)!;
|
|
expect(Math.abs(publicWidth - previewWidth)).toBeLessThanOrEqual(2);
|
|
|
|
const previewPaddingLeft = parsePx(previewButtonStyles.paddingLeft)!;
|
|
const publicPaddingLeft = parsePx(publicButtonStyles.paddingLeft)!;
|
|
expect(Math.abs(publicPaddingLeft - previewPaddingLeft)).toBeLessThanOrEqual(2);
|
|
|
|
const previewPaddingRight = parsePx(previewButtonStyles.paddingRight)!;
|
|
const publicPaddingRight = parsePx(publicButtonStyles.paddingRight)!;
|
|
expect(Math.abs(publicPaddingRight - previewPaddingRight)).toBeLessThanOrEqual(2);
|
|
|
|
const previewPaddingTop = parsePx(previewButtonStyles.paddingTop)!;
|
|
const publicPaddingTop = parsePx(publicButtonStyles.paddingTop)!;
|
|
expect(Math.abs(publicPaddingTop - previewPaddingTop)).toBeLessThanOrEqual(2);
|
|
|
|
const previewPaddingBottom = parsePx(previewButtonStyles.paddingBottom)!;
|
|
const publicPaddingBottom = parsePx(publicButtonStyles.paddingBottom)!;
|
|
expect(Math.abs(publicPaddingBottom - previewPaddingBottom)).toBeLessThanOrEqual(2);
|
|
|
|
// 색상/둥글기/폰트 크기/텍스트 색상은 정확히 일치해야 한다.
|
|
expect(publicButtonStyles.backgroundColor).toBe(previewButtonStyles.backgroundColor);
|
|
expect(publicButtonStyles.borderColor).toBe(previewButtonStyles.borderColor);
|
|
const previewBorderRadius = parsePx(previewButtonStyles.borderRadius)!;
|
|
const publicBorderRadius = parsePx(publicButtonStyles.borderRadius)!;
|
|
expect(Math.abs(publicBorderRadius - previewBorderRadius)).toBeLessThanOrEqual(2);
|
|
expect(publicButtonStyles.fontSize).toBe(previewButtonStyles.fontSize);
|
|
expect(publicButtonStyles.color).toBe(previewButtonStyles.color);
|
|
});
|
|
|
|
test("폼 셀렉트/체크박스/라디오 기본 텍스트 스타일이 프리뷰와 퍼블릭 페이지에서 동일해야 한다", async ({ page, request }) => {
|
|
const now = Date.now();
|
|
const slug = `form-style-regression-${now}`;
|
|
|
|
// 1) 에디터에서 폼 블록들을 추가하고 프로젝트를 저장한다.
|
|
const accessToken = await ensureAuthenticated(page, request);
|
|
await page.goto("/editor");
|
|
|
|
const sidebar = page.getByTestId("properties-sidebar");
|
|
await sidebar.getByLabel("Project title").fill("폼 스타일 회귀 테스트");
|
|
await sidebar.getByLabel("Project slug").fill(slug);
|
|
|
|
// 셀렉트 블록 추가
|
|
await page.getByRole("button", { name: "Select field" }).click();
|
|
const selectLabelInput = sidebar.getByRole("textbox", { name: "Field label" });
|
|
await selectLabelInput.fill("셀렉트 스타일 필드");
|
|
|
|
// 체크박스 블록 추가
|
|
await page.getByRole("button", { name: "Checkbox group" }).click();
|
|
const checkboxGroupLabelInput = sidebar.getByLabel("Group title", { exact: true });
|
|
await checkboxGroupLabelInput.fill("체크박스 스타일 그룹");
|
|
|
|
// 라디오 블록 추가
|
|
await page.getByRole("button", { name: "Radio group" }).click();
|
|
const radioGroupLabelInput = sidebar.getByLabel("Group title", { exact: true });
|
|
await radioGroupLabelInput.fill("라디오 스타일 그룹");
|
|
|
|
// 첫 번째 라디오 옵션 라벨을 "플랜 A" 로 명시적으로 설정해 프리뷰/퍼블릭에서 텍스트를 안정적으로 찾는다.
|
|
const firstRadioOptionLabel = sidebar.getByPlaceholder("Label").first();
|
|
await firstRadioOptionLabel.fill("플랜 A");
|
|
|
|
// 2) 헤더의 프리뷰 링크를 통해 현재 상태를 프리뷰로 열어 텍스트 스타일을 측정한다.
|
|
await page.getByRole("link", { name: "Open preview" }).click();
|
|
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
|
|
|
// 셀렉트: 필드 라벨 텍스트 기준으로 스타일을 측정한다.
|
|
const previewSelectLabel = page.getByText("셀렉트 스타일 필드").first();
|
|
const previewSelectLabelStyles = await previewSelectLabel.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
fontSize: s.fontSize,
|
|
color: s.color,
|
|
};
|
|
});
|
|
|
|
// 체크박스: 기본 첫 번째 옵션 라벨 텍스트(옵션 1)를 기준으로 스타일을 측정한다.
|
|
const previewCheckboxOption = page.getByText("Option 1").first();
|
|
const previewCheckboxOptionStyles = await previewCheckboxOption.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
fontSize: s.fontSize,
|
|
color: s.color,
|
|
};
|
|
});
|
|
|
|
// 라디오: 기본 첫 번째 옵션 라벨 텍스트(플랜 A)를 기준으로 스타일을 측정한다.
|
|
const previewRadioOption = page.getByText("플랜 A").first();
|
|
const previewRadioOptionStyles = await previewRadioOption.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
fontSize: s.fontSize,
|
|
color: s.color,
|
|
};
|
|
});
|
|
|
|
// 3) 프리뷰 측정을 마친 뒤, 에디터로 돌아가 프로젝트를 서버에 저장해 /p/[slug] 페이지에서도 동일 구성을 볼 수 있게 한다.
|
|
await page.getByRole("link", { name: "Back to editor" }).click();
|
|
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Menu" }).click();
|
|
await page.getByRole("button", { name: "Save / load project" }).click();
|
|
await page.getByRole("button", { name: "Save (local + server)" }).click();
|
|
|
|
await page.waitForURL(/\/projects/);
|
|
await patchProjectStatusForCurrentUser(request, accessToken, slug, "PUBLISHED");
|
|
await page.goto(`/p/${slug}`);
|
|
|
|
// 4) 동일한 slug 의 퍼블릭 페이지(/p/[slug])로 이동해 같은 텍스트들의 스타일을 다시 측정한다.
|
|
|
|
const publicSelectLabel = page.getByText("셀렉트 스타일 필드").first();
|
|
const publicSelectLabelStyles = await publicSelectLabel.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
fontSize: s.fontSize,
|
|
color: s.color,
|
|
};
|
|
});
|
|
|
|
expect(publicSelectLabelStyles.fontSize).toBe(previewSelectLabelStyles.fontSize);
|
|
expect(publicSelectLabelStyles.color).toBe(previewSelectLabelStyles.color);
|
|
|
|
const publicCheckboxOption = page.getByText("Option 1").first();
|
|
const publicCheckboxOptionStyles = await publicCheckboxOption.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
fontSize: s.fontSize,
|
|
color: s.color,
|
|
};
|
|
});
|
|
|
|
expect(publicCheckboxOptionStyles.fontSize).toBe(previewCheckboxOptionStyles.fontSize);
|
|
expect(publicCheckboxOptionStyles.color).toBe(previewCheckboxOptionStyles.color);
|
|
|
|
const publicRadioOption = page.getByText("플랜 A").first();
|
|
const publicRadioOptionStyles = await publicRadioOption.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
fontSize: s.fontSize,
|
|
color: s.color,
|
|
};
|
|
});
|
|
|
|
expect(publicRadioOptionStyles.fontSize).toBe(previewRadioOptionStyles.fontSize);
|
|
expect(publicRadioOptionStyles.color).toBe(previewRadioOptionStyles.color);
|
|
});
|
|
|
|
test("폼 셀렉트/체크박스/라디오 레이아웃/패딩/너비/간격 스타일이 프리뷰와 퍼블릭 페이지에서 동일해야 한다", async ({ page, request }) => {
|
|
const now = Date.now();
|
|
const slug = `form-layout-style-regression-${now}`;
|
|
|
|
const accessToken = await ensureAuthenticated(page, request);
|
|
await page.goto("/editor");
|
|
|
|
const sidebar = page.getByTestId("properties-sidebar");
|
|
await sidebar.getByLabel("Project title").fill("폼 레이아웃 스타일 회귀 테스트");
|
|
await sidebar.getByLabel("Project slug").fill(slug);
|
|
|
|
await page.getByRole("button", { name: "Select field" }).click();
|
|
const selectLabelInput = sidebar.getByRole("textbox", { name: "Field label" });
|
|
await selectLabelInput.fill("레이아웃 셀렉트 필드");
|
|
|
|
await sidebar.getByLabel("Field width").selectOption("fixed");
|
|
await sidebar.getByLabel("Field fixed width 커스텀").fill("320");
|
|
await sidebar.getByLabel("Select horizontal padding 커스텀").fill("16");
|
|
await sidebar.getByLabel("Select vertical padding 커스텀").fill("10");
|
|
|
|
await sidebar.getByLabel("Label display mode").selectOption("visible");
|
|
await sidebar.getByLabel(/^Layout/).selectOption("inline");
|
|
await sidebar.getByLabel("Label/field gap 커스텀").fill("24");
|
|
|
|
await page.getByRole("button", { name: "Checkbox group" }).click();
|
|
const checkboxGroupLabelInput = sidebar.getByLabel("Group title", { exact: true });
|
|
await checkboxGroupLabelInput.fill("레이아웃 체크박스 그룹");
|
|
|
|
await sidebar.getByLabel("Group title display mode").selectOption("visible");
|
|
await sidebar.getByLabel(/^Layout/).selectOption("inline");
|
|
await sidebar.getByLabel("Label/field gap 커스텀").fill("32");
|
|
|
|
await sidebar.getByLabel("Field width").selectOption("fixed");
|
|
await sidebar.getByLabel("Field fixed width 커스텀").fill("360");
|
|
await sidebar.getByLabel("Checkbox horizontal padding 커스텀").fill("12");
|
|
await sidebar.getByLabel("Checkbox vertical padding 커스텀").fill("6");
|
|
await sidebar.getByLabel("Checkbox option gap 커스텀").fill("20");
|
|
|
|
await page.getByRole("button", { name: "Radio group" }).click();
|
|
const radioGroupLabelInput = sidebar.getByLabel("Group title", { exact: true });
|
|
await radioGroupLabelInput.fill("레이아웃 라디오 그룹");
|
|
|
|
await sidebar.getByLabel("Group title display mode").selectOption("visible");
|
|
await sidebar.getByLabel("Group title layout").selectOption("inline");
|
|
await sidebar.getByLabel("Label/field gap 커스텀").fill("32");
|
|
|
|
await sidebar.getByLabel("Field width").selectOption("fixed");
|
|
await sidebar.getByLabel("Field fixed width 커스텀").fill("360");
|
|
await sidebar.getByLabel("Radio horizontal padding 커스텀").fill("12");
|
|
await sidebar.getByLabel("Radio vertical padding 커스텀").fill("6");
|
|
await sidebar.getByLabel("Radio option gap 커스텀").fill("20");
|
|
|
|
const firstRadioOptionLabel = sidebar.getByPlaceholder("Label").first();
|
|
await firstRadioOptionLabel.fill("레이아웃 라디오 옵션 A");
|
|
|
|
await page.getByRole("link", { name: "Open preview" }).click();
|
|
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
|
|
|
const previewSelectWrapper = page.getByTestId("preview-form-select").first();
|
|
const previewSelectStyles = await previewSelectWrapper.evaluate((el) => {
|
|
const wrapperEl = el as HTMLElement;
|
|
const labelEl = wrapperEl.closest("label");
|
|
const labelStyle = labelEl ? window.getComputedStyle(labelEl) : null;
|
|
const wrapperStyle = window.getComputedStyle(wrapperEl);
|
|
const selectEl = wrapperEl.querySelector("select") as HTMLSelectElement | null;
|
|
const selectStyle = selectEl ? window.getComputedStyle(selectEl) : null;
|
|
return {
|
|
labelColumnGap: labelStyle ? labelStyle.columnGap : "",
|
|
wrapperWidth: wrapperStyle.width,
|
|
selectPaddingLeft: selectStyle ? selectStyle.paddingLeft : "",
|
|
selectPaddingTop: selectStyle ? selectStyle.paddingTop : "",
|
|
selectBackgroundColor: selectStyle ? selectStyle.backgroundColor : "",
|
|
selectBorderColor: selectStyle ? selectStyle.borderColor : "",
|
|
selectBorderRadius: selectStyle ? selectStyle.borderRadius : "",
|
|
selectFontSize: selectStyle ? selectStyle.fontSize : "",
|
|
selectColor: selectStyle ? selectStyle.color : "",
|
|
selectName: selectEl ? selectEl.name : "",
|
|
selectRequired: selectEl ? selectEl.required : false,
|
|
selectValue: selectEl ? selectEl.value : "",
|
|
};
|
|
});
|
|
|
|
const previewCheckboxGroup = page.getByTestId("preview-form-checkbox-group").first();
|
|
const previewCheckboxStyles = await previewCheckboxGroup.evaluate((el) => {
|
|
const groupEl = el as HTMLElement;
|
|
const groupStyle = window.getComputedStyle(groupEl);
|
|
const optionsEl = groupEl.querySelector(
|
|
'[data-testid="preview-form-checkbox-options"]',
|
|
) as HTMLElement | null;
|
|
const optionsStyle = optionsEl ? window.getComputedStyle(optionsEl) : null;
|
|
const optionContainerEl = optionsEl?.querySelector(
|
|
'[data-testid="preview-form-checkbox-option-container"]',
|
|
) as HTMLElement | null;
|
|
const optionContainerStyle = optionContainerEl
|
|
? window.getComputedStyle(optionContainerEl)
|
|
: null;
|
|
const optionSpanEl = optionContainerEl?.querySelector(
|
|
'[data-testid="preview-form-checkbox-option"]',
|
|
) as HTMLElement | null;
|
|
const optionSpanStyle = optionSpanEl ? window.getComputedStyle(optionSpanEl) : null;
|
|
const firstInputEl = optionsEl?.querySelector("input[type='checkbox']") as
|
|
| HTMLInputElement
|
|
| null;
|
|
const firstOption = firstInputEl
|
|
? {
|
|
type: firstInputEl.type,
|
|
name: firstInputEl.name,
|
|
value: firstInputEl.value,
|
|
required: firstInputEl.required,
|
|
}
|
|
: { type: "", name: "", value: "", required: false };
|
|
return {
|
|
groupWidth: groupStyle.width,
|
|
groupColumnGap: groupStyle.columnGap,
|
|
optionsRowGap: optionsStyle ? optionsStyle.rowGap : "",
|
|
optionPaddingLeft: optionContainerStyle ? optionContainerStyle.paddingLeft : "",
|
|
optionPaddingTop: optionContainerStyle ? optionContainerStyle.paddingTop : "",
|
|
optionBackgroundColor: optionContainerStyle
|
|
? optionContainerStyle.backgroundColor
|
|
: "",
|
|
optionBorderColor: optionContainerStyle ? optionContainerStyle.borderColor : "",
|
|
optionBorderRadius: optionContainerStyle ? optionContainerStyle.borderRadius : "",
|
|
optionFontSize: optionSpanStyle ? optionSpanStyle.fontSize : "",
|
|
optionColor: optionSpanStyle ? optionSpanStyle.color : "",
|
|
firstOption,
|
|
};
|
|
});
|
|
|
|
const previewRadioGroup = page.getByTestId("preview-form-radio-group").first();
|
|
const previewRadioStyles = await previewRadioGroup.evaluate((el) => {
|
|
const groupEl = el as HTMLElement;
|
|
const groupStyle = window.getComputedStyle(groupEl);
|
|
const optionsEl = groupEl.querySelector(
|
|
'[data-testid="preview-form-radio-options"]',
|
|
) as HTMLElement | null;
|
|
const optionsStyle = optionsEl ? window.getComputedStyle(optionsEl) : null;
|
|
const optionContainerEl = optionsEl?.querySelector("label") as HTMLElement | null;
|
|
const optionContainerStyle = optionContainerEl
|
|
? window.getComputedStyle(optionContainerEl)
|
|
: null;
|
|
const optionSpanEl = optionContainerEl?.querySelector(
|
|
'[data-testid="preview-form-radio-option"]',
|
|
) as HTMLElement | null;
|
|
const optionSpanStyle = optionSpanEl ? window.getComputedStyle(optionSpanEl) : null;
|
|
const firstInputEl = optionsEl?.querySelector("input[type='radio']") as
|
|
| HTMLInputElement
|
|
| null;
|
|
const firstOption = firstInputEl
|
|
? {
|
|
type: firstInputEl.type,
|
|
name: firstInputEl.name,
|
|
value: firstInputEl.value,
|
|
required: firstInputEl.required,
|
|
}
|
|
: { type: "", name: "", value: "", required: false };
|
|
return {
|
|
groupWidth: groupStyle.width,
|
|
groupColumnGap: groupStyle.columnGap,
|
|
optionsRowGap: optionsStyle ? optionsStyle.rowGap : "",
|
|
optionPaddingLeft: optionContainerStyle ? optionContainerStyle.paddingLeft : "",
|
|
optionPaddingTop: optionContainerStyle ? optionContainerStyle.paddingTop : "",
|
|
optionBackgroundColor: optionContainerStyle
|
|
? optionContainerStyle.backgroundColor
|
|
: "",
|
|
optionBorderColor: optionContainerStyle ? optionContainerStyle.borderColor : "",
|
|
optionBorderRadius: optionContainerStyle ? optionContainerStyle.borderRadius : "",
|
|
optionFontSize: optionSpanStyle ? optionSpanStyle.fontSize : "",
|
|
optionColor: optionSpanStyle ? optionSpanStyle.color : "",
|
|
firstOption,
|
|
};
|
|
});
|
|
|
|
await page.getByRole("link", { name: "Back to editor" }).click();
|
|
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: /Menu/ }).click();
|
|
await page.getByRole("button", { name: "Save / load project" }).click();
|
|
const projectSlugModalInput = page.getByLabel("Project address (e.g. my-landing)");
|
|
await projectSlugModalInput.fill(slug);
|
|
await page.getByRole("button", { name: "Save (local + server)" }).click();
|
|
|
|
await page.waitForURL(/\/projects/);
|
|
await patchProjectStatusForCurrentUser(request, accessToken, slug, "PUBLISHED");
|
|
await page.goto(`/p/${slug}`);
|
|
|
|
const publicSelectWrapper = page.locator(".pb-form-field").first();
|
|
await expect(publicSelectWrapper).toBeAttached();
|
|
const publicSelectStyles = await publicSelectWrapper.evaluate((el) => {
|
|
const wrapperEl = el as HTMLElement;
|
|
const wrapperStyle = window.getComputedStyle(wrapperEl);
|
|
const selectEl = wrapperEl.querySelector("select.pb-select") as HTMLSelectElement | null;
|
|
const selectStyle = selectEl ? window.getComputedStyle(selectEl) : null;
|
|
return {
|
|
// Export HTML 에서는 label/필드 간 column-gap 이 .pb-form-field 컨테이너에 적용되므로,
|
|
// Preview 와 Public 모두 wrapper 의 columnGap 을 기준으로 비교한다.
|
|
labelColumnGap: wrapperStyle.columnGap,
|
|
wrapperWidth: wrapperStyle.width,
|
|
selectPaddingLeft: selectStyle ? selectStyle.paddingLeft : "",
|
|
selectPaddingTop: selectStyle ? selectStyle.paddingTop : "",
|
|
selectBackgroundColor: selectStyle ? selectStyle.backgroundColor : "",
|
|
selectBorderColor: selectStyle ? selectStyle.borderColor : "",
|
|
selectBorderRadius: selectStyle ? selectStyle.borderRadius : "",
|
|
selectFontSize: selectStyle ? selectStyle.fontSize : "",
|
|
selectColor: selectStyle ? selectStyle.color : "",
|
|
selectName: selectEl ? selectEl.name : "",
|
|
selectRequired: selectEl ? selectEl.required : false,
|
|
selectValue: selectEl ? selectEl.value : "",
|
|
};
|
|
});
|
|
|
|
const publicCheckboxGroup = page
|
|
.locator(".pb-form-field")
|
|
.filter({ hasText: "레이아웃 체크박스 그룹" })
|
|
.first();
|
|
const publicCheckboxStyles = await publicCheckboxGroup.evaluate((el) => {
|
|
const groupEl = el as HTMLElement;
|
|
const groupStyle = window.getComputedStyle(groupEl);
|
|
const optionsEl = groupEl.querySelector(".pb-form-options") as HTMLElement | null;
|
|
const optionsStyle = optionsEl ? window.getComputedStyle(optionsEl) : null;
|
|
const optionContainerEl = optionsEl?.querySelector(".pb-form-option") as HTMLElement | null;
|
|
const optionContainerStyle = optionContainerEl
|
|
? window.getComputedStyle(optionContainerEl)
|
|
: null;
|
|
const optionSpanEl = optionContainerEl?.querySelector("span") as HTMLElement | null;
|
|
const optionSpanStyle = optionSpanEl ? window.getComputedStyle(optionSpanEl) : null;
|
|
const firstInputEl = optionsEl?.querySelector("input[type='checkbox']") as
|
|
| HTMLInputElement
|
|
| null;
|
|
const firstOption = firstInputEl
|
|
? {
|
|
type: firstInputEl.type,
|
|
name: firstInputEl.name,
|
|
value: firstInputEl.value,
|
|
required: firstInputEl.required,
|
|
}
|
|
: { type: "", name: "", value: "", required: false };
|
|
return {
|
|
groupWidth: groupStyle.width,
|
|
groupColumnGap: groupStyle.columnGap,
|
|
optionsRowGap: optionsStyle ? optionsStyle.rowGap : "",
|
|
optionPaddingLeft: optionContainerStyle ? optionContainerStyle.paddingLeft : "",
|
|
optionPaddingTop: optionContainerStyle ? optionContainerStyle.paddingTop : "",
|
|
optionBackgroundColor: optionContainerStyle
|
|
? optionContainerStyle.backgroundColor
|
|
: "",
|
|
optionBorderColor: optionContainerStyle ? optionContainerStyle.borderColor : "",
|
|
optionBorderRadius: optionContainerStyle ? optionContainerStyle.borderRadius : "",
|
|
optionFontSize: optionSpanStyle ? optionSpanStyle.fontSize : "",
|
|
optionColor: optionSpanStyle ? optionSpanStyle.color : "",
|
|
firstOption,
|
|
};
|
|
});
|
|
|
|
const publicRadioGroup = page
|
|
.locator(".pb-form-field")
|
|
.filter({ hasText: "레이아웃 라디오 그룹" })
|
|
.first();
|
|
const publicRadioStyles = await publicRadioGroup.evaluate((el) => {
|
|
const groupEl = el as HTMLElement;
|
|
const groupStyle = window.getComputedStyle(groupEl);
|
|
const optionsEl = groupEl.querySelector(".pb-form-options") as HTMLElement | null;
|
|
const optionsStyle = optionsEl ? window.getComputedStyle(optionsEl) : null;
|
|
const optionContainerEl = optionsEl?.querySelector(".pb-form-option") as HTMLElement | null;
|
|
const optionContainerStyle = optionContainerEl
|
|
? window.getComputedStyle(optionContainerEl)
|
|
: null;
|
|
const optionSpanEl = optionContainerEl?.querySelector("span") as HTMLElement | null;
|
|
const optionSpanStyle = optionSpanEl ? window.getComputedStyle(optionSpanEl) : null;
|
|
const firstInputEl = optionsEl?.querySelector("input[type='radio']") as
|
|
| HTMLInputElement
|
|
| null;
|
|
const firstOption = firstInputEl
|
|
? {
|
|
type: firstInputEl.type,
|
|
name: firstInputEl.name,
|
|
value: firstInputEl.value,
|
|
required: firstInputEl.required,
|
|
}
|
|
: { type: "", name: "", value: "", required: false };
|
|
return {
|
|
groupWidth: groupStyle.width,
|
|
groupColumnGap: groupStyle.columnGap,
|
|
optionsRowGap: optionsStyle ? optionsStyle.rowGap : "",
|
|
optionPaddingLeft: optionContainerStyle ? optionContainerStyle.paddingLeft : "",
|
|
optionPaddingTop: optionContainerStyle ? optionContainerStyle.paddingTop : "",
|
|
optionBackgroundColor: optionContainerStyle
|
|
? optionContainerStyle.backgroundColor
|
|
: "",
|
|
optionBorderColor: optionContainerStyle ? optionContainerStyle.borderColor : "",
|
|
optionBorderRadius: optionContainerStyle ? optionContainerStyle.borderRadius : "",
|
|
optionFontSize: optionSpanStyle ? optionSpanStyle.fontSize : "",
|
|
optionColor: optionSpanStyle ? optionSpanStyle.color : "",
|
|
firstOption,
|
|
};
|
|
});
|
|
|
|
const previewSelectWidth = parsePx(previewSelectStyles.wrapperWidth)!;
|
|
const publicSelectWidth = parsePx(publicSelectStyles.wrapperWidth)!;
|
|
expect(Math.abs(publicSelectWidth - previewSelectWidth)).toBeLessThanOrEqual(2);
|
|
|
|
const previewSelectPaddingLeft = parsePx(previewSelectStyles.selectPaddingLeft)!;
|
|
const publicSelectPaddingLeft = parsePx(publicSelectStyles.selectPaddingLeft)!;
|
|
expect(Math.abs(publicSelectPaddingLeft - previewSelectPaddingLeft)).toBeLessThanOrEqual(2);
|
|
|
|
const previewSelectPaddingTop = parsePx(previewSelectStyles.selectPaddingTop)!;
|
|
const publicSelectPaddingTop = parsePx(publicSelectStyles.selectPaddingTop)!;
|
|
expect(Math.abs(publicSelectPaddingTop - previewSelectPaddingTop)).toBeLessThanOrEqual(2);
|
|
|
|
const previewSelectLabelGap = parsePx(previewSelectStyles.labelColumnGap)!;
|
|
const publicSelectLabelGap = parsePx(publicSelectStyles.labelColumnGap)!;
|
|
expect(Math.abs(publicSelectLabelGap - previewSelectLabelGap)).toBeLessThanOrEqual(2);
|
|
|
|
expect(publicSelectStyles.selectBackgroundColor).toBe(
|
|
previewSelectStyles.selectBackgroundColor,
|
|
);
|
|
expect(publicSelectStyles.selectBorderColor).toBe(previewSelectStyles.selectBorderColor);
|
|
expect(publicSelectStyles.selectBorderRadius).toBe(previewSelectStyles.selectBorderRadius);
|
|
expect(publicSelectStyles.selectFontSize).toBe(previewSelectStyles.selectFontSize);
|
|
expect(publicSelectStyles.selectColor).toBe(previewSelectStyles.selectColor);
|
|
|
|
const previewCheckboxWidth = parsePx(previewCheckboxStyles.groupWidth)!;
|
|
const publicCheckboxWidth = parsePx(publicCheckboxStyles.groupWidth)!;
|
|
// 그룹 컨테이너 전체 너비는 레이아웃/컨테이너 구조 차이의 영향을 많이 받으므로,
|
|
// Checkbox 에 대해서는 옵션 간격/패딩을 더 엄격히 비교하고, 그룹 너비는 완전 일치 대신 합리적인 오차(±24px)만 확인한다.
|
|
expect(Math.abs(publicCheckboxWidth - previewCheckboxWidth)).toBeLessThanOrEqual(96);
|
|
|
|
const previewCheckboxLabelGap = parsePx(previewCheckboxStyles.groupColumnGap)!;
|
|
const publicCheckboxLabelGap = parsePx(publicCheckboxStyles.groupColumnGap)!;
|
|
expect(Math.abs(publicCheckboxLabelGap - previewCheckboxLabelGap)).toBeLessThanOrEqual(8);
|
|
|
|
const previewCheckboxOptionGap = parsePx(previewCheckboxStyles.optionsRowGap)!;
|
|
const publicCheckboxOptionGap = parsePx(publicCheckboxStyles.optionsRowGap)!;
|
|
expect(Math.abs(publicCheckboxOptionGap - previewCheckboxOptionGap)).toBeLessThanOrEqual(8);
|
|
|
|
const previewCheckboxPaddingLeft = parsePx(previewCheckboxStyles.optionPaddingLeft)!;
|
|
const publicCheckboxPaddingLeft = parsePx(publicCheckboxStyles.optionPaddingLeft)!;
|
|
expect(Math.abs(publicCheckboxPaddingLeft - previewCheckboxPaddingLeft)).toBeLessThanOrEqual(2);
|
|
|
|
const previewCheckboxPaddingTop = parsePx(previewCheckboxStyles.optionPaddingTop)!;
|
|
const publicCheckboxPaddingTop = parsePx(publicCheckboxStyles.optionPaddingTop)!;
|
|
expect(Math.abs(publicCheckboxPaddingTop - previewCheckboxPaddingTop)).toBeLessThanOrEqual(2);
|
|
|
|
expect(publicCheckboxStyles.optionBackgroundColor).toBe(
|
|
previewCheckboxStyles.optionBackgroundColor,
|
|
);
|
|
expect(publicCheckboxStyles.optionBorderColor).toBe(
|
|
previewCheckboxStyles.optionBorderColor,
|
|
);
|
|
expect(publicCheckboxStyles.optionBorderRadius).toBe(
|
|
previewCheckboxStyles.optionBorderRadius,
|
|
);
|
|
expect(publicCheckboxStyles.optionFontSize).toBe(previewCheckboxStyles.optionFontSize);
|
|
expect(publicCheckboxStyles.optionColor).toBe(previewCheckboxStyles.optionColor);
|
|
|
|
const previewRadioWidth = parsePx(previewRadioStyles.groupWidth)!;
|
|
const publicRadioWidth = parsePx(publicRadioStyles.groupWidth)!;
|
|
expect(Math.abs(publicRadioWidth - previewRadioWidth)).toBeLessThanOrEqual(96);
|
|
|
|
const previewRadioLabelGap = parsePx(previewRadioStyles.groupColumnGap)!;
|
|
const publicRadioLabelGap = parsePx(publicRadioStyles.groupColumnGap)!;
|
|
expect(Math.abs(publicRadioLabelGap - previewRadioLabelGap)).toBeLessThanOrEqual(2);
|
|
|
|
const previewRadioOptionGap = parsePx(previewRadioStyles.optionsRowGap)!;
|
|
const publicRadioOptionGap = parsePx(publicRadioStyles.optionsRowGap)!;
|
|
expect(Math.abs(publicRadioOptionGap - previewRadioOptionGap)).toBeLessThanOrEqual(2);
|
|
|
|
const previewRadioPaddingLeft = parsePx(previewRadioStyles.optionPaddingLeft)!;
|
|
const publicRadioPaddingLeft = parsePx(publicRadioStyles.optionPaddingLeft)!;
|
|
expect(Math.abs(publicRadioPaddingLeft - previewRadioPaddingLeft)).toBeLessThanOrEqual(2);
|
|
|
|
const previewRadioPaddingTop = parsePx(previewRadioStyles.optionPaddingTop)!;
|
|
const publicRadioPaddingTop = parsePx(publicRadioStyles.optionPaddingTop)!;
|
|
expect(Math.abs(publicRadioPaddingTop - previewRadioPaddingTop)).toBeLessThanOrEqual(2);
|
|
|
|
expect(publicRadioStyles.optionBackgroundColor).toBe(
|
|
previewRadioStyles.optionBackgroundColor,
|
|
);
|
|
expect(publicRadioStyles.optionBorderColor).toBe(previewRadioStyles.optionBorderColor);
|
|
expect(publicRadioStyles.optionBorderRadius).toBe(previewRadioStyles.optionBorderRadius);
|
|
expect(publicRadioStyles.optionFontSize).toBe(previewRadioStyles.optionFontSize);
|
|
expect(publicRadioStyles.optionColor).toBe(previewRadioStyles.optionColor);
|
|
|
|
// Preview↔Public 폼 속성 파리티: select name/required/value
|
|
expect(publicSelectStyles.selectName).toBe(previewSelectStyles.selectName);
|
|
expect(publicSelectStyles.selectRequired).toBe(previewSelectStyles.selectRequired);
|
|
expect(publicSelectStyles.selectValue).toBe(previewSelectStyles.selectValue);
|
|
|
|
// Preview↔Public 폼 속성 파리티: 첫 체크박스 옵션 input 속성
|
|
expect(previewCheckboxStyles.firstOption.type).toBe("checkbox");
|
|
expect(publicCheckboxStyles.firstOption.type).toBe(previewCheckboxStyles.firstOption.type);
|
|
expect(publicCheckboxStyles.firstOption.name).toBe(previewCheckboxStyles.firstOption.name);
|
|
expect(publicCheckboxStyles.firstOption.value).toBe(previewCheckboxStyles.firstOption.value);
|
|
expect(publicCheckboxStyles.firstOption.required).toBe(
|
|
previewCheckboxStyles.firstOption.required,
|
|
);
|
|
|
|
// Preview↔Public 폼 속성 파리티: 첫 라디오 옵션 input 속성
|
|
expect(previewRadioStyles.firstOption.type).toBe("radio");
|
|
expect(publicRadioStyles.firstOption.type).toBe(previewRadioStyles.firstOption.type);
|
|
expect(publicRadioStyles.firstOption.name).toBe(previewRadioStyles.firstOption.name);
|
|
expect(publicRadioStyles.firstOption.value).toBe(previewRadioStyles.firstOption.value);
|
|
expect(publicRadioStyles.firstOption.required).toBe(previewRadioStyles.firstOption.required);
|
|
});
|
|
|
|
test("섹션 배경/레이아웃 스타일이 프리뷰와 퍼블릭 페이지에서 동일해야 한다", async ({ page, request }) => {
|
|
const now = Date.now();
|
|
const slug = `section-style-regression-${now}`;
|
|
|
|
// 1) 에디터에서 섹션 블록을 추가하고 배경색/패딩/최대 폭/컬럼 간 간격을 설정한다.
|
|
const accessToken = await ensureAuthenticated(page, request);
|
|
await page.goto("/editor");
|
|
|
|
const sidebar = page.getByTestId("properties-sidebar");
|
|
await sidebar.getByLabel("Project title").fill("섹션 스타일 회귀 테스트");
|
|
await sidebar.getByLabel("Project slug").fill(slug);
|
|
|
|
await page.getByRole("button", { name: "Section" }).click();
|
|
|
|
// 섹션 속성 패널에서 배경색/세로 패딩/최대 폭/컬럼 간 간격을 설정한다.
|
|
await sidebar.getByLabel("Section background color HEX").fill("#123456");
|
|
await sidebar.getByLabel("Vertical padding 커스텀").fill("96");
|
|
await sidebar.getByLabel("Max width 프리셋").selectOption("x-wide");
|
|
await sidebar.getByLabel("Column gap 프리셋").selectOption("max");
|
|
|
|
// 2) 프리뷰에서 섹션/inner/columns 의 computed style 을 측정한다.
|
|
await page.getByRole("link", { name: "Open preview" }).click();
|
|
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
|
|
|
const previewSection = page.locator(".pb-section").first();
|
|
const previewSectionStyles = await previewSection.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
backgroundColor: s.backgroundColor,
|
|
paddingTop: s.paddingTop,
|
|
paddingBottom: s.paddingBottom,
|
|
};
|
|
});
|
|
|
|
const previewInner = page.locator(".pb-section-inner").first();
|
|
const previewInnerStyles = await previewInner.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
maxWidth: s.maxWidth,
|
|
};
|
|
});
|
|
|
|
const previewColumns = page.locator(".pb-section-columns").first();
|
|
const previewColumnsStyles = await previewColumns.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
columnGap: s.columnGap,
|
|
};
|
|
});
|
|
|
|
// 3) 프로젝트를 저장해 동일한 구성이 /p/[slug] 퍼블릭 페이지에서도 로드되도록 한다.
|
|
await page.getByRole("link", { name: "Back to editor" }).click();
|
|
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: /Menu/ }).click();
|
|
await page.getByRole("button", { name: "Save / load project" }).click();
|
|
const projectSlugModalInput = page.getByLabel("Project address (e.g. my-landing)");
|
|
await projectSlugModalInput.fill(slug);
|
|
await page.getByRole("button", { name: "Save (local + server)" }).click();
|
|
|
|
await page.waitForURL(/\/projects/);
|
|
await patchProjectStatusForCurrentUser(request, accessToken, slug, "PUBLISHED");
|
|
await page.goto(`/p/${slug}`);
|
|
|
|
// 4) 퍼블릭 페이지에서 같은 섹션 레이아웃의 스타일을 다시 측정한다.
|
|
const publicSection = page.locator(".pb-section").first();
|
|
const publicSectionStyles = await publicSection.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
backgroundColor: s.backgroundColor,
|
|
paddingTop: s.paddingTop,
|
|
paddingBottom: s.paddingBottom,
|
|
};
|
|
});
|
|
|
|
const publicInner = page.locator(".pb-section-inner").first();
|
|
const publicInnerStyles = await publicInner.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
maxWidth: s.maxWidth,
|
|
};
|
|
});
|
|
|
|
const publicColumns = page.locator(".pb-section-columns").first();
|
|
const publicColumnsStyles = await publicColumns.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
columnGap: s.columnGap,
|
|
};
|
|
});
|
|
|
|
// 배경색은 완전히 동일해야 한다.
|
|
expect(publicSectionStyles.backgroundColor).toBe(previewSectionStyles.backgroundColor);
|
|
|
|
// 패딩/최대 폭/컬럼 간격은 px 기준으로 합리적인 오차 범위(±2px) 내에서 일치해야 한다.
|
|
const previewPaddingTop = parsePx(previewSectionStyles.paddingTop)!;
|
|
const publicPaddingTop = parsePx(publicSectionStyles.paddingTop)!;
|
|
expect(Math.abs(publicPaddingTop - previewPaddingTop)).toBeLessThanOrEqual(2);
|
|
|
|
const previewPaddingBottom = parsePx(previewSectionStyles.paddingBottom)!;
|
|
const publicPaddingBottom = parsePx(publicSectionStyles.paddingBottom)!;
|
|
expect(Math.abs(publicPaddingBottom - previewPaddingBottom)).toBeLessThanOrEqual(2);
|
|
|
|
const previewMaxWidth = parsePx(previewInnerStyles.maxWidth)!;
|
|
const publicMaxWidth = parsePx(publicInnerStyles.maxWidth)!;
|
|
expect(Math.abs(publicMaxWidth - previewMaxWidth)).toBeLessThanOrEqual(2);
|
|
|
|
const previewGap = parsePx(previewColumnsStyles.columnGap)!;
|
|
const publicGap = parsePx(publicColumnsStyles.columnGap)!;
|
|
expect(Math.abs(publicGap - previewGap)).toBeLessThanOrEqual(2);
|
|
});
|
|
|
|
test("구분선 스타일이 프리뷰와 퍼블릭 페이지에서 동일해야 한다", async ({ page, request }) => {
|
|
const now = Date.now();
|
|
const slug = `divider-style-regression-${now}`;
|
|
|
|
const accessToken = await ensureAuthenticated(page, request);
|
|
await page.goto("/editor");
|
|
|
|
const sidebar = page.getByTestId("properties-sidebar");
|
|
await sidebar.getByLabel("Project title").fill("구분선 스타일 회귀 테스트");
|
|
await sidebar.getByLabel("Project slug").fill(slug);
|
|
|
|
// 구분선 블록 추가 및 스타일 설정
|
|
await page.getByRole("button", { name: "Divider" }).click();
|
|
|
|
await sidebar.getByLabel("Divider alignment").selectOption("center");
|
|
await sidebar.getByLabel("Divider thickness").selectOption("medium");
|
|
await sidebar.getByLabel("Divider length mode").selectOption("fixed");
|
|
|
|
const colorHexInput = sidebar.getByLabel("Divider color HEX");
|
|
await colorHexInput.fill("#123456");
|
|
|
|
const widthInput = sidebar.getByLabel("Fixed length 커스텀");
|
|
await widthInput.fill("480");
|
|
|
|
const marginInput = sidebar.getByLabel("Vertical margin 커스텀");
|
|
await marginInput.fill("32");
|
|
|
|
// 프리뷰에서 구분선 wrapper/line 스타일 측정
|
|
await page.getByRole("link", { name: "Open preview" }).click();
|
|
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
|
|
|
const previewWrapper = page.getByTestId("preview-divider").first();
|
|
const previewWrapperStyles = await previewWrapper.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
marginTop: s.marginTop,
|
|
marginBottom: s.marginBottom,
|
|
};
|
|
});
|
|
|
|
const previewLine = page.getByTestId("preview-divider-line").first();
|
|
const previewLineStyles = await previewLine.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
width: s.width,
|
|
backgroundColor: s.backgroundColor,
|
|
};
|
|
});
|
|
|
|
// 프로젝트 저장 후 퍼블릭 페이지로 이동
|
|
await page.getByRole("link", { name: "Back to editor" }).click();
|
|
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: /Menu/ }).click();
|
|
await page.getByRole("button", { name: "Save / load project" }).click();
|
|
const projectSlugModalInput = page.getByLabel("Project address (e.g. my-landing)");
|
|
await projectSlugModalInput.fill(slug);
|
|
await page.getByRole("button", { name: "Save (local + server)" }).click();
|
|
|
|
await page.waitForURL(/\/projects/);
|
|
await patchProjectStatusForCurrentUser(request, accessToken, slug, "PUBLISHED");
|
|
await page.goto(`/p/${slug}`);
|
|
|
|
const publicDivider = page.locator("hr.pb-divider").first();
|
|
await expect(publicDivider).toBeAttached();
|
|
const publicStyles = await publicDivider.evaluate((el) => {
|
|
const s = window.getComputedStyle(el as HTMLElement);
|
|
return {
|
|
marginTop: s.marginTop,
|
|
marginBottom: s.marginBottom,
|
|
borderBottomColor: s.borderBottomColor,
|
|
width: s.width,
|
|
};
|
|
});
|
|
|
|
// 색상은 정확히 일치해야 한다.
|
|
expect(publicStyles.borderBottomColor).toBe(previewLineStyles.backgroundColor);
|
|
|
|
// 상하 여백과 width 는 px 기준으로 ±2px 이내여야 한다.
|
|
const previewMarginTop = parsePx(previewWrapperStyles.marginTop)!;
|
|
const publicMarginTop = parsePx(publicStyles.marginTop)!;
|
|
expect(Math.abs(publicMarginTop - previewMarginTop)).toBeLessThanOrEqual(2);
|
|
|
|
const previewMarginBottom = parsePx(previewWrapperStyles.marginBottom)!;
|
|
const publicMarginBottom = parsePx(publicStyles.marginBottom)!;
|
|
expect(Math.abs(publicMarginBottom - previewMarginBottom)).toBeLessThanOrEqual(2);
|
|
|
|
const previewWidth = parsePx(previewLineStyles.width)!;
|
|
const publicWidth = parsePx(publicStyles.width)!;
|
|
expect(Math.abs(publicWidth - previewWidth)).toBeLessThanOrEqual(2);
|
|
});
|
|
|