Files
Gnuboard7/tests/Playwright/specs/layout-editor/action-flex-condition.spec.ts
T
HeuJung 0f0ef8fa8d test(e2e): 편집기 저장 spec 전용 시드 화면 분리 + 실패 10건 수정
레이아웃 편집기 spec 중 저장(PUT)까지 수행하는 것들이 제품 화면에 편집 결과를
영속시켜, 실행할 때마다 개발 사이트에 노드가 누적됐다(home 에 빈 표 7개,
관리자 대시보드에 빈 차트 5개). spec 안에 "추가한 노드 삭제 후 재저장" 원복을
넣어도 소용이 없었다 — 원복 후에도 레이아웃이 오염 시점과 정확히 같은 크기로
되돌아왔다. 원복에 기대는 대신 저장 대상 자체를 E2E 전용 시드 화면으로 분리한다.

시드 화면은 활성 템플릿 디렉토리에만 설치되므로 배포 원본과 릴리스 산출물은
변하지 않는다. globalSetup 이 매 실행 fixture 원본으로 덮어써 회차 간 누적이
성립하지 않으므로 저장 spec 에 원복 절차가 필요 없다. 설치는 레이아웃 파일과
라우트, DB 행 세 가지를 함께 다루되 시드 행만 건드린다 — 전체 재동기화를 쓰면
편집기로 저장한(파일에 없는) 변경이 실행할 때마다 사라지기 때문이다.

전수 실행에서 드러난 기존 실패도 함께 처리했다. 35건 중 27건은 절전·부하
아티팩트였고 나머지는 원인을 확정해 고쳤다. 템플릿 식별자 자리에 모듈 id 를
넣어 라우트 트리가 비어 있었고, "캔버스 텍스트가 길어진다"는 대리 지표가 렌더
여부와 무관했으며(정상 렌더에도 짧아진다), 팔레트 삽입 위치가 잠금 서브트리
안이면 선택이 조상으로 밀려 노드를 지목할 수 없었고, 상태 개수 단언이 상태
추가로 낡았고, if 게이트상 그 탭에 등록되지 않는 소스를 refetch 하고 있었고,
무통신 구간이 오지 않는 관리자 화면에 networkidle 을 기다렸고, 테스트 종료 후
도착한 route 콜백이 워커를 중단시켰고, 같은 설정 화면에 저장하는 테스트들이
병렬로 충돌했다.

측정 방법에서 반복해 틀린 지점들은 E2E 가이드에 표로 남겼다 — 가림은 가시성
판정으로 잡히지 않고, 길이 증감은 렌더 신호가 아니며, 총 개수 단언은 스펙이
늘면 깨지고, networkidle 은 실시간 연결이 있는 화면에서 오지 않는다.
2026-07-31 00:43:58 +09:00

217 lines
11 KiB
TypeScript

/**
* Layout Editor — 동작/표시조건/정렬박스 탭.
*
* S7 검증:
* - 요소 선택 → ⓘ → 속성 설정 → 모달에 "동작"/"표시조건" 탭 노출(events/visibility 선언 시)
* - 표시조건 탭: 친화 조건 빌더(택1 + AND/OR + 미리보기) 렌더
* - 동작 탭: 이벤트 슬롯 + 친화 동작 추가(핸들러 용어 미노출)
* - 정렬 박스(flex) 컨트롤: Flex/Div 선택 시 스타일 탭 상단에 정렬 박스 섹션
*
* Chrome MCP/Playwright 빌트인 drag 는 dnd-kit 비호환(메모리 feedback_chrome_mcp_dnd_kit_incompatible)
* 이라, 본 spec 은 탭 노출/빌더 렌더/조작 결과의 가시 상태만 확인하고 합성 로직 정합성은
* 단위/RTL(actionRecipeEngine/conditionRecipeEngine/s7-property-controls)이 잠근다.
*
* @scenario property_modal_action_tab + property_modal_visibility_tab + flex_editor_section
* @effects action_tab_visible_when_events + visibility_builder_renders + flex_section_for_container
*/
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
type PwPage = import('@playwright/test').Page;
async function openFirstNodeModal(page: PwPage): Promise<void> {
await page.goto('/admin/layout-editor/sirsoft-basic?route=%2F');
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 });
}
/**
* 캔버스의 visible 노드 path 목록(앞쪽 N개)을 수집한다.
*
* 캔버스 노드는 드래그 핸들(`g7le-dnd-handle-*`)이 덮어 실제 마우스 클릭이 노드에 닿지
* 못하므로(핸들이 pointer events 가로챔), path 로 노드를 식별해 selectNode 합성 이벤트로
* 선택한다. 반응형 hidden 노드(mobile_header 등)는 제외한다.
*/
async function visibleNodePaths(page: PwPage, limit: number): Promise<string[]> {
return page.evaluate((max) => {
const out: string[] = [];
const els = Array.from(document.querySelectorAll('[data-editor-path]'));
for (const el of els) {
const r = (el as HTMLElement).getBoundingClientRect();
const visible = r.width > 0 && r.height > 0 && getComputedStyle(el as HTMLElement).display !== 'none';
const path = el.getAttribute('data-editor-path');
if (visible && path) out.push(path);
if (out.length >= max) break;
}
return out;
}, limit);
}
/**
* path 로 노드를 단일 선택한다 — 합성 마우스 이벤트 시퀀스로 드래그 핸들의 onClick(선택
* 위임)을 거치지 않고 캔버스 선택 로직을 직접 트리거한다(핸들 pointer 가로채기 우회).
*/
async function selectNode(page: PwPage, editorPath: string): Promise<boolean> {
return page.evaluate((p) => {
const el = document.querySelector(`[data-editor-path="${p}"]`);
if (!el) return false;
const r = el.getBoundingClientRect();
for (const t of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
el.dispatchEvent(new MouseEvent(t, { bubbles: true, cancelable: true, clientX: r.left + 8, clientY: r.top + 8, view: window }));
}
return true;
}, editorPath);
}
test.describe('@layout-editor 동작/표시조건/정렬박스 (S7)', () => {
test('요소 선택 → 속성 모달에 표시조건 탭 + 조건 빌더 렌더', async ({ page }) => {
const token = issueToken('core.templates.layouts.edit');
await authenticatePage(page, token);
await openFirstNodeModal(page);
// 표시조건 탭은 capability 보유 요소에서 노출된다. 여러 노드를 시도해 탭이 있는 노드를 찾는다.
const paths = await visibleNodePaths(page, 8);
let found = false;
for (const path of paths) {
if (!(await selectNode(page, path))) continue;
const info = page.getByTestId('g7le-overlay-info-button');
if ((await info.count()) === 0) continue;
await info.click();
const editProps = page.getByTestId('g7le-context-menu-edit-props');
if ((await editProps.count()) === 0) continue;
await editProps.click();
await page.waitForSelector('[data-testid="g7le-property-modal"]', { timeout: 8_000 });
if ((await page.getByTestId('g7le-property-tab-visibility').count()) > 0) {
await page.getByTestId('g7le-property-tab-visibility').click();
// 조건 빌더(친화 목록) 또는 빈 안내 중 하나가 렌더
const builder = await page.getByTestId('g7le-condition-builder').count();
const noRecipes = await page.getByTestId('g7le-condition-no-recipes').count();
expect(builder + noRecipes).toBeGreaterThan(0);
found = true;
break;
}
await page.getByTestId('g7le-property-modal-close').click();
}
expect(found).toBe(true);
});
test('표시조건 빌더 — 조건 추가 시 미리보기에 단일 {{ }} 식 노출', async ({ page }) => {
const token = issueToken('core.templates.layouts.edit');
await authenticatePage(page, token);
await openFirstNodeModal(page);
const paths = await visibleNodePaths(page, 8);
for (const path of paths) {
if (!(await selectNode(page, path))) continue;
const info = page.getByTestId('g7le-overlay-info-button');
if ((await info.count()) === 0) continue;
await info.click();
const editProps = page.getByTestId('g7le-context-menu-edit-props');
if ((await editProps.count()) === 0) continue;
await editProps.click();
await page.waitForSelector('[data-testid="g7le-property-modal"]', { timeout: 8_000 });
if ((await page.getByTestId('g7le-property-tab-visibility').count()) > 0) {
await page.getByTestId('g7le-property-tab-visibility').click();
if ((await page.getByTestId('g7le-condition-add-and').count()) > 0) {
await page.getByTestId('g7le-condition-add-and').click();
const preview = page.getByTestId('g7le-condition-preview');
await expect(preview).toBeVisible();
const text = await preview.textContent();
// 단일 {{ }} 한 쌍 — 중첩 보간 없음
expect((text?.match(/\{\{/g) ?? []).length).toBe(1);
return;
}
}
await page.getByTestId('g7le-property-modal-close').click();
}
});
// ── D-D 회귀 — 컴포넌트 [동작] 탭 ──
// @scenario property_modal_action_tab
// @effects nested_onsuccess_picker_not_empty + action_card_drag_handle_draggable
/** [동작] 탭을 가진(events capability) 노드를 찾아 동작 탭을 연다. 못 찾으면 null.
* Button 등 onClick capability 보유 가능성이 높은 노드를 우선 시도한다(앞쪽 컨테이너는
* 보통 events 미보유 → 동작 탭 없음). */
async function openActionTab(page: PwPage): Promise<boolean> {
const paths = await page.evaluate(() => {
const els = Array.from(document.querySelectorAll('[data-editor-path]')) as HTMLElement[];
const visible = els.filter((el) => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0 && getComputedStyle(el).display !== 'none';
});
// Button 류 우선, 그 외 후순위
const buttons = visible.filter((el) => el.tagName === 'BUTTON').map((el) => el.getAttribute('data-editor-path')!);
const others = visible.filter((el) => el.tagName !== 'BUTTON').slice(0, 8).map((el) => el.getAttribute('data-editor-path')!);
return [...buttons.slice(0, 12), ...others];
});
for (const path of paths) {
if (!(await selectNode(page, path))) continue;
const info = page.getByTestId('g7le-overlay-info-button');
if ((await info.count()) === 0) continue;
await info.click();
const editProps = page.getByTestId('g7le-context-menu-edit-props');
if ((await editProps.count()) === 0) continue;
await editProps.click();
await page.waitForSelector('[data-testid="g7le-property-modal"]', { timeout: 8_000 });
if ((await page.getByTestId('g7le-property-tab-action').count()) > 0) {
await page.getByTestId('g7le-property-tab-action').click();
if ((await page.getByTestId('g7le-action-editor').count()) > 0) return true;
}
await page.getByTestId('g7le-property-modal-close').click();
}
return false;
}
test('동작 탭 — apiCall 의 성공 시 동작(중첩) add picker 가 코어 핸들러를 노출(빈 상태 금지)', async ({ page }) => {
const token = issueToken('core.templates.layouts.edit');
await authenticatePage(page, token);
await openFirstNodeModal(page);
expect(await openActionTab(page)).toBe(true);
// 첫 이벤트 슬롯에 apiCall(서버 호출) 동작 추가. (슬롯에 기존 카드가 있을 수 있으므로
// 추가 후 "맨 끝 카드"가 방금 추가한 apiCall — 그 카드의 edit 을 연다.)
// 동작 편집기가 공용 ActionListBuilder 로 공통화되면서 testid 체계가 바뀌었다.
// 이벤트 슬롯 빌더의 prefix 는 `g7le-action-slot-{eventName}` 이고, 중첩 목록은
// `{prefix}-edit-{paramKey}` 를 다시 prefix 로 받는다. 인덱스는 add 토글이 아니라
// 카드(item/edit/drag)에 붙는다.
const SLOT = 'g7le-action-slot-onClick';
const NESTED = `${SLOT}-edit-onSuccess`;
const firstSlot = page.locator('[data-testid="g7le-action-event-onClick"]').first();
await firstSlot.locator(`[data-testid="${SLOT}-add-picker-toggle"]`).click();
await firstSlot.locator('[data-testid="g7le-init-action-spec-apiCall"]').click();
// 추가된 apiCall 카드(슬롯의 마지막 카드) 편집 → onSuccess 중첩 영역.
const editBtns = firstSlot.locator(`[data-testid^="${SLOT}-edit-"]`);
await editBtns.last().click();
const onSuccess = page.getByTestId(`${SLOT}-edit-param-onSuccess`);
await expect(onSuccess).toBeVisible();
// 빈 상태("사용할 수 있는 동작이 없습니다") 가 아니라 중첩 add 토글이 있어야 한다(rawRecipes 전달).
await expect(onSuccess.getByTestId(`${NESTED}-add-picker-empty`)).toHaveCount(0);
await expect(onSuccess.getByTestId(`${NESTED}-add-picker-toggle`)).toHaveCount(1);
// 펼치면 코어 핸들러(안내 메시지 등) 노출.
await onSuccess.getByTestId(`${NESTED}-add-picker-toggle`).click();
await expect(onSuccess.getByTestId('g7le-init-action-spec-toast')).toHaveCount(1);
});
test('동작 탭 — 동작 카드 드래그 핸들(⠿)이 draggable(순서 재배치 가능)', async ({ page }) => {
const token = issueToken('core.templates.layouts.edit');
await authenticatePage(page, token);
await openFirstNodeModal(page);
expect(await openActionTab(page)).toBe(true);
// 동작 1개 추가(toast) → 카드 생성 → 드래그 핸들 draggable 확인.
// 이벤트 슬롯 빌더 prefix = `g7le-action-slot-{eventName}` (공용 ActionListBuilder 공통화).
// 어떤 이벤트 슬롯이 렌더되는지는 노드에 따라 다르므로 접미사 패턴으로 첫 슬롯을 집는다.
const slotAddToggle = page
.locator('[data-testid="g7le-action-editor"] [data-testid$="-add-picker-toggle"]')
.first();
await slotAddToggle.click();
const toastSpec = page.locator('[data-testid="g7le-action-editor"] [data-testid="g7le-init-action-spec-toast"]').first();
await toastSpec.click();
const handle = page.locator('[data-testid="g7le-action-editor"] [data-testid*="-drag-"]').first();
await expect(handle).toBeVisible();
await expect(handle).toHaveAttribute('draggable', 'true');
});
});