/** * Layout Editor — image 위젯 값 형태(단일 값 슬롯 축약 / 표시모드 억제 / 표현식 보호). * * 결함(공개 #135): 코어 `image` 위젯이 내보내는 `{url,size,repeat,position}` 객체를 * `propValue` 경로가 그대로 `props[key]` 에 기록해, 소비 컴포넌트가 `` 를 * 받고 브라우저가 `[object Object]` 를 URL 로 해석했다. 이 결함은 **브라우저에서만** * 드러난다 — 깨진 요청은 SPA catch-all 때문에 404 조차 아니라 200(HTML)을 받고, 편집기 * 위젯의 미리보기는 정상이며, 서버 로그에도 흔적이 없다. 화면의 엑박이 유일한 증상이다. * * 단위(Vitest)는 `applyRecipe` 반환 노드까지만 볼 수 있어 "실제 `` 의 src 가 문자열이고 * naturalWidth > 0" 을 포착하지 못한다. 그래서 캔버스 DOM 을 직접 재는 spec 을 둔다. * * 축 요약(마커 아님 — 평문): image_propvalue_narrowing, display_mode_suppression, binding_expression_guard, styleprop_bundle_regression. * 효과 요약(마커 아님 — 평문): image_object_narrows_to_url_string_in_propvalue_slot, single_value_slot_control_hides_display_mode_buttons_entirely, bound_value_shows_expression_badge_and_locks_destructive_controls, styleprop_bundle_four_property_decomposition_is_unchanged. */ import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth'; import { SANDBOX_ROUTE, sandboxRouteParam } from '../../fixtures/seed-layout'; import type { Page } from '@playwright/test'; const EDITOR_URL = '/admin/layout-editor/sirsoft-basic?route=%2F'; /** * 공통 레이아웃 편집 모드 — `Header` 의 「로고 이미지」는 `_user_base` 소유다. * * 라우트 모드에서 그 노드는 **상속(base) 출처**라 ⓘ 대신 「🔒 공통 레이아웃 편집」이 뜬다. * 편집해도 저장 시 마스킹이 통째로 폐기하므로 편집 표면을 열지 않는 것이 계약이다 * (계획서 D9~D11). 따라서 그 컨트롤을 여는 정상 경로는 이 모드다. */ const BASE_EDITOR_URL = '/admin/layout-editor/sirsoft-basic?edit=__base__%2F_user_base'; /** * 편집기에 진입해 캔버스 편집 노드가 렌더될 때까지 기다린다. */ async function openEditor(page: Page, url: string = EDITOR_URL): Promise { const token = issueToken('core.templates.layouts.edit'); await authenticatePage(page, token); await page.goto(url); await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); await page.waitForSelector('[data-testid="g7le-preview-frame"]', { timeout: 30_000 }); await page.waitForFunction( () => document.querySelectorAll('[data-editor-path]').length > 0, { timeout: 20_000 }, ); } /** * 캔버스 위임 click 핸들러로 노드를 선택한다. 패널/오버레이가 좌표를 가로채는 것을 피해 * 편집기가 실제로 받는 pointer/click 시퀀스를 노드 중앙에 직접 발사한다. */ async function selectNode(page: Page, path: string): Promise { await page.evaluate((p) => { const el = document.querySelector(`[data-editor-path="${p}"]`); if (!el) return; el.scrollIntoView({ block: 'center' }); const r = el.getBoundingClientRect(); const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; for (const type of ['pointerover', 'pointermove', 'pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) { el.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX: cx, clientY: cy })); } }, path); } /** ⓘ → 「속성 설정」 → 속성 편집 모달. 지정한 탭을 연다. */ async function openPropertyTab(page: Page, tab: 'props' | 'style'): Promise { await page.waitForSelector('[data-testid="g7le-overlay-info-button"]', { timeout: 10_000 }); await page.getByTestId('g7le-overlay-info-button').click(); await page.waitForSelector('[data-testid="g7le-context-menu-edit-props"]', { timeout: 5_000 }); await page.getByTestId('g7le-context-menu-edit-props').click(); await page.waitForSelector('[data-testid="g7le-property-modal"]', { timeout: 10_000 }); await page.getByTestId(`g7le-property-tab-${tab}`).click(); } /** * `image` 위젯이 열린 상태에서 **단일 값 슬롯 컨트롤**을 찾을 수 있는 노드를 고른다. * * 리터럴 `data-editor-path` 를 쓰지 않는다 — 베이스 루트 인덱스가 밀리면 통째로 깨진다. * 대신 캔버스에서 `Header` 컴포넌트를 이름으로 찾는다(로고 컨트롤의 소유 컴포넌트). */ async function findHeaderPath(page: Page): Promise { return page.evaluate(() => { const el = document.querySelector('[data-editor-path][data-editor-name="Header"]'); return el?.getAttribute('data-editor-path') ?? null; }); } /** * 시드 화면이 소유한 `Header` 의 편집 경로를 id 로 지목한다. * * 시드 화면은 `_user_base` 를 extends 하므로 캔버스에 `Header` 가 **둘** 뜬다 — 상속받은 * 베이스 헤더(잠금: ⓘ 대신 「공통 레이아웃 편집」)와 시드가 직접 둔 라우트 소유 헤더. * 이름으로만 찾으면 앞의 잠긴 쪽이 잡혀 속성 모달에 도달하지 못한다. */ async function findSandboxHeaderPath(page: Page): Promise { return page.evaluate(() => { const el = document.querySelector('[data-editor-path][data-editor-id="e2e_sandbox_header"]'); return el?.getAttribute('data-editor-path') ?? null; }); } /** 배경이 보일 만한 박스형 Div — styleProp 묶음(회귀 가드)의 대상 */ async function pickBoxAreaPath(page: Page): Promise { return page.evaluate(() => { const cands = Array.from( document.querySelectorAll('[data-editor-path][data-editor-name="Div"]'), ).filter((el) => { const r = el.getBoundingClientRect(); return r.width > 200 && r.width < 800 && r.height > 80 && r.height < 320 && r.left > 360; }); return cands[0]?.getAttribute('data-editor-path') ?? null; }); } /** * 저장 페이로드에서 `logo` prop 값을 찾아 돌려준다 (트리 어디에 있든). * * 노드 경로를 리터럴로 박으면 베이스 레이아웃 구조가 한 칸만 바뀌어도 통째로 깨지므로 * 키 이름으로 전역 탐색한다. * * @param value 저장 요청 body (JSON) * @returns 찾은 `logo` 값, 없으면 undefined */ function findLogoProp(value: unknown): unknown { // 저장 페이로드의 `content` 는 **JSON 문자열**로 실린다 — 파싱하지 않으면 트리를 볼 수 없다. if (typeof value === 'string') { if (!value.trim().startsWith('{')) return undefined; try { return findLogoProp(JSON.parse(value)); } catch { return undefined; } } if (Array.isArray(value)) { for (const item of value) { const hit = findLogoProp(item); if (hit !== undefined) return hit; } return undefined; } if (!value || typeof value !== 'object') return undefined; const record = value as Record; const props = record.props; if (props && typeof props === 'object' && 'logo' in (props as Record)) { return (props as Record).logo; } for (const child of Object.values(record)) { const hit = findLogoProp(child); if (hit !== undefined) return hit; } return undefined; } /** 1x1 PNG — 첨부 업로드용 최소 실물 이미지(naturalWidth>0 을 재려면 실제 파일이어야 한다) */ const PNG_1PX = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64', ); test.describe('@layout-editor image 위젯 값 형태 (공개 #135)', () => { /** @effects image_object_narrows_to_url_string_in_propvalue_slot, single_value_slot_control_hides_display_mode_buttons_entirely */ test('로고 이미지 URL 입력 → 캔버스 img src 가 문자열, [object Object] 0건, 모드 버튼 미노출', async ({ page }) => { await openEditor(page, BASE_EDITOR_URL); const headerPath = await findHeaderPath(page); test.skip(headerPath === null, '캔버스에서 Header 노드를 찾지 못했습니다'); await selectNode(page, headerPath as string); await openPropertyTab(page, 'props'); const urlInput = page.getByTestId('g7le-image-url'); test.skip(!(await urlInput.isVisible().catch(() => false)), '로고 이미지 컨트롤이 노출되지 않았습니다'); // 저장값이 데이터 연결 표현식이면 컨트롤은 보호 상태로 뜬다(입력칸 readOnly). // 값을 바꾸려면 「직접 지정으로 바꾸기」로 명시적으로 열어야 한다 — 그 한 번의 // 의사표시 없이는 환경설정과의 연결이 덮이지 않는다는 것이 계약이다. const replace = page.getByTestId('g7le-image-expression-replace'); if (await replace.isVisible().catch(() => false)) { await replace.click(); // 해제되면 되돌리기 경로가 함께 열린다(편도 금지). await expect(page.getByTestId('g7le-image-expression-restore')).toBeVisible(); } // 단일 값 슬롯(propValue) 이므로 표시모드 버튼은 **컨테이너째** 미렌더되어야 한다. // `disabled` 로 남기면 "URL 을 넣으면 살아나겠지" 라는 거짓 정보를 준다. await expect(page.getByTestId('g7le-image-modes')).toHaveCount(0); await expect(page.getByTestId('g7le-image-mode-fill')).toHaveCount(0); const url = '/api/templates/sirsoft-basic/layout-attachments/5/file'; await urlInput.fill(url); await urlInput.blur(); // 핵심 검증 — 캔버스의 실제 가 문자열 src 를 받아야 한다. // 종전에는 여기에 "[object Object]" 가 들어가 엑박이 됐고, SPA catch-all 때문에 // 그 요청은 404 조차 아니라 200(HTML) 이라 네트워크 탭으로도 드러나지 않았다. await expect .poll( async () => page.evaluate((p) => { const host = document.querySelector(`[data-editor-path="${p}"]`); const img = host?.querySelector('img'); return img?.getAttribute('src') ?? ''; }, headerPath), { timeout: 8_000 }, ) .toContain('layout-attachments/5/file'); const objectObjectCount = await page.evaluate(() => Array.from(document.images).filter((i) => (i.getAttribute('src') ?? '').includes('[object Object]')).length, ); expect(objectObjectCount, '문서 어디에도 [object Object] src 가 없어야 합니다').toBe(0); }); /** * 저장 → 사용자 화면 3단 — 이 결함의 **최종 소비 지점**이다. * * 위 케이스는 편집기 캔버스까지만 본다. 그런데 #135 가 실제로 드러난 자리는 저장 이후의 * 사용자 화면이고, 깨진 요청은 SPA catch-all 때문에 404 조차 아니라 200(HTML) 이라 * 네트워크 탭·서버 로그 어디에도 흔적이 없다. 그래서 「저장 페이로드가 문자열인가」와 * 「그 문자열이 실제로 그려지는가(naturalWidth>0)」를 여기서 함께 잰다. * * 대상은 **시드 화면(`e2e_sandbox`)** 이다. 이 spec 은 저장(PUT)까지 수행하므로 제품 * 레이아웃(`_user_base`)을 쓰면 편집 결과가 그대로 영속돼 개발 사이트가 오염된다 * (`fixtures/seed-layout.ts` 규약). 시드 화면에 라우트 소유 `Header` 를 두어 로고 컨트롤을 * 라우트 모드에서 열 수 있게 했고, 시드는 globalTeardown 이 통째로 제거한다. * * @effects image_object_narrows_to_url_string_in_propvalue_slot, img_string_src_passes_through_unchanged */ test('저장 → 사용자 화면에서 문자열 src 로 실제 렌더된다', async ({ page }) => { // 편집기 진입 + 저장 왕복 + 사용자 화면 재방문 3단이라 기본 30s 로는 부족하다. test.setTimeout(120_000); // 실제로 그려지는지(naturalWidth>0)를 재려면 실존하는 이미지가 필요하다. 고정 id 를 // 박으면 그 첨부가 지워진 환경에서 조용히 0 이 되므로, 이 spec 이 직접 올리고 지운다. const token = issueToken('core.templates.layouts.edit'); const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' }; const upload = await page.request.post('/api/admin/templates/sirsoft-basic/layout-attachments', { headers, multipart: { file: { name: 'e2e-logo.png', mimeType: 'image/png', buffer: PNG_1PX }, }, }); test.skip(!upload.ok(), `첨부 업로드 실패(${upload.status()})`); const attachmentId = (await upload.json())?.data?.id; test.skip(!attachmentId, '첨부 id 를 받지 못했습니다'); const url = `/api/templates/sirsoft-basic/layout-attachments/${attachmentId}/file`; try { await openEditor(page, `/admin/layout-editor/sirsoft-basic?route=${sandboxRouteParam()}`); const headerPath = await findSandboxHeaderPath(page); test.skip(headerPath === null, '시드 화면에서 Header 노드를 찾지 못했습니다'); await selectNode(page, headerPath as string); await openPropertyTab(page, 'props'); const urlInput = page.getByTestId('g7le-image-url'); test.skip(!(await urlInput.isVisible().catch(() => false)), '로고 이미지 컨트롤이 노출되지 않았습니다'); const replace = page.getByTestId('g7le-image-expression-replace'); if (await replace.isVisible().catch(() => false)) await replace.click(); await urlInput.fill(url); await urlInput.blur(); // 속성 모달을 닫고 툴바에서 저장한다. await page.getByTestId('g7le-property-modal-done').click(); // 저장 페이로드의 `logo` 가 **문자열**이어야 한다 — 객체면 수정 전 상태다. const [put] = await Promise.all([ page.waitForResponse( (r) => r.url().includes('/layouts/e2e_sandbox') && r.request().method() === 'PUT', { timeout: 30_000 }, ), page.getByTestId('g7le-toolbar-save').click(), ]); expect(put.status(), '저장이 200 이어야 합니다').toBe(200); const logo = findLogoProp(put.request().postDataJSON()); expect(typeof logo, `저장 페이로드의 logo 가 문자열이어야 합니다 (실제: ${JSON.stringify(logo)})`).toBe('string'); expect(logo).toContain('layout-attachments/5/file'); // 3단 — 사용자 화면에서 실제로 그려지는가. await page.goto(SANDBOX_ROUTE['sirsoft-basic']); await page.waitForLoadState('domcontentloaded'); const measured = await page.evaluate(async () => { await new Promise((r) => setTimeout(r, 1500)); const imgs = Array.from(document.images); const hit = imgs.find((i) => (i.getAttribute('src') ?? '').includes('layout-attachments/5/file')); return { src: hit?.getAttribute('src') ?? null, natural: hit?.naturalWidth ?? 0, objectObject: imgs.filter((i) => (i.getAttribute('src') ?? '').includes('[object Object]')).length, }; }); expect(measured.objectObject, '사용자 화면에 [object Object] src 가 없어야 합니다').toBe(0); expect(measured.src, '사용자 화면의 로고 src 가 저장한 문자열이어야 합니다').toContain( 'layout-attachments/5/file', ); expect(measured.natural, '로고가 실제로 로드되어야 합니다(naturalWidth>0)').toBeGreaterThan(0); } finally { // 올린 첨부는 반드시 지운다 — 실패로 중단돼도 서버에 잔여물을 남기지 않는다. await page.request .delete(`/api/admin/templates/layout-attachments/${attachmentId}`, { headers }) .catch(() => undefined); } }); /** @effects bound_value_shows_expression_badge_and_locks_destructive_controls */ test('저장값이 데이터 연결 표현식이면 원문 배지 + 파괴적 조작 잠금', async ({ page }) => { await openEditor(page, BASE_EDITOR_URL); const headerPath = await findHeaderPath(page); test.skip(headerPath === null, '캔버스에서 Header 노드를 찾지 못했습니다'); await selectNode(page, headerPath as string); await openPropertyTab(page, 'props'); const badge = page.getByTestId('g7le-image-expression'); // 번들 레이아웃의 기본 저장값은 사이트 로고 설정 표현식이다. 표현식 상태가 아니면 // (운영자가 이미 고정 URL 을 넣은 사이트) 이 축은 검증 대상이 아니다. test.skip(!(await badge.isVisible().catch(() => false)), '로고가 표현식 상태가 아닙니다'); // 원문이 그대로 보여야 한다 — 빈 피커로 보이면 업로드 1클릭에 표현식이 소리 없이 사라진다. await expect(badge).toContainText('{{'); // 미리보기는 깨지므로 렌더하지 않는다(거짓 미리보기 금지). await expect(page.getByTestId('g7le-image-preview')).toHaveCount(0); // 파괴적 조작은 잠긴다 — 단, 「직접 지정으로 바꾸기」로 즉시 해제되는 일시적 상태다. await expect(page.getByTestId('g7le-image-clear')).toBeDisabled(); await expect(page.getByTestId('g7le-image-url')).toHaveAttribute('readonly', /.*/); await page.getByTestId('g7le-image-expression-replace').click(); await expect(page.getByTestId('g7le-image-expression')).toHaveCount(0); await expect(page.getByTestId('g7le-image-clear')).toBeEnabled(); }); /** @effects styleprop_bundle_four_property_decomposition_is_unchanged */ test('회귀 가드 — 배경 이미지(styleProp 묶음) 컨트롤은 모드 버튼과 4속성 분해를 유지', async ({ page }) => { await openEditor(page); const targetPath = await pickBoxAreaPath(page); test.skip(targetPath === null, '배경을 넣을 박스형 Div 를 찾지 못했습니다'); await selectNode(page, targetPath as string); await openPropertyTab(page, 'style'); const urlInput = page.getByTestId('g7le-image-url'); test.skip(!(await urlInput.isVisible().catch(() => false)), '배경 이미지 컨트롤이 노출되지 않았습니다'); // 다중 props 묶음이라 표시모드 버튼은 그대로 있어야 한다(단일 슬롯 억제가 여기까지 // 번지면 배경 편집 기능이 통째로 죽는다). await expect(page.getByTestId('g7le-image-modes')).toBeVisible(); const url = 'https://example.com/api/templates/sirsoft-basic/layout-attachments/5/file'; await urlInput.fill(url); await urlInput.blur(); await expect .poll( async () => page.evaluate((p) => { const el = document.querySelector(`[data-editor-path="${p}"]`); return el ? getComputedStyle(el).backgroundImage : ''; }, targetPath), { timeout: 8_000 }, ) .toContain('layout-attachments/5/file'); const snap = await page.evaluate((p) => { const el = document.querySelector(`[data-editor-path="${p}"]`) as HTMLElement | null; const cs = el ? getComputedStyle(el) : null; return { size: cs?.backgroundSize, repeat: cs?.backgroundRepeat, image: cs?.backgroundImage }; }, targetPath); expect(snap.size).toBe('cover'); expect(snap.repeat).toBe('no-repeat'); expect(snap.image, 'CSS 값 문맥이므로 url(...) 로 감싸져야 합니다').toContain('url('); expect(snap.image).not.toContain('[object Object]'); }); });