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) { 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", }, ]); } test("텍스트/버튼/리스트/이미지/비디오/폼 입력 필드 스타일이 프리뷰와 퍼블릭 페이지에서 동일해야 한다", async ({ page, request }) => { const now = Date.now(); const slug = `style-regression-${now}`; // 1) 에디터에서 프로젝트 slug 와 기본 블록들을 구성한다. 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, }; }); // 버튼 (프리뷰에서는 로 렌더링됨) 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 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}`; 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 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) 에디터에서 폼 블록들을 추가하고 프로젝트를 저장한다. 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 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}`; 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 page.goto(`/p/${slug}`); const publicSelectWrapper = page .locator(".pb-form-field") .filter({ hasText: "레이아웃 셀렉트 필드" }) .first(); 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) 에디터에서 섹션 블록을 추가하고 배경색/패딩/최대 폭/컬럼 간격을 설정한다. 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 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}`; 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 page.goto(`/p/${slug}`); const publicDivider = page.locator("hr.pb-divider").first(); 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); });