레이아웃 편집기 spec 중 저장(PUT)까지 수행하는 것들이 제품 화면에 편집 결과를 영속시켜, 실행할 때마다 개발 사이트에 노드가 누적됐다(home 에 빈 표 7개, 관리자 대시보드에 빈 차트 5개). spec 안에 "추가한 노드 삭제 후 재저장" 원복을 넣어도 소용이 없었다 — 원복 후에도 레이아웃이 오염 시점과 정확히 같은 크기로 되돌아왔다. 원복에 기대는 대신 저장 대상 자체를 E2E 전용 시드 화면으로 분리한다. 시드 화면은 활성 템플릿 디렉토리에만 설치되므로 배포 원본과 릴리스 산출물은 변하지 않는다. globalSetup 이 매 실행 fixture 원본으로 덮어써 회차 간 누적이 성립하지 않으므로 저장 spec 에 원복 절차가 필요 없다. 설치는 레이아웃 파일과 라우트, DB 행 세 가지를 함께 다루되 시드 행만 건드린다 — 전체 재동기화를 쓰면 편집기로 저장한(파일에 없는) 변경이 실행할 때마다 사라지기 때문이다. 전수 실행에서 드러난 기존 실패도 함께 처리했다. 35건 중 27건은 절전·부하 아티팩트였고 나머지는 원인을 확정해 고쳤다. 템플릿 식별자 자리에 모듈 id 를 넣어 라우트 트리가 비어 있었고, "캔버스 텍스트가 길어진다"는 대리 지표가 렌더 여부와 무관했으며(정상 렌더에도 짧아진다), 팔레트 삽입 위치가 잠금 서브트리 안이면 선택이 조상으로 밀려 노드를 지목할 수 없었고, 상태 개수 단언이 상태 추가로 낡았고, if 게이트상 그 탭에 등록되지 않는 소스를 refetch 하고 있었고, 무통신 구간이 오지 않는 관리자 화면에 networkidle 을 기다렸고, 테스트 종료 후 도착한 route 콜백이 워커를 중단시켰고, 같은 설정 화면에 저장하는 테스트들이 병렬로 충돌했다. 측정 방법에서 반복해 틀린 지점들은 E2E 가이드에 표로 남겼다 — 가림은 가시성 판정으로 잡히지 않고, 길이 증감은 렌더 신호가 아니며, 총 개수 단언은 스펙이 늘면 깨지고, networkidle 은 실시간 연결이 있는 화면에서 오지 않는다.
150 lines
7.4 KiB
TypeScript
150 lines
7.4 KiB
TypeScript
/**
|
|
* Layout Editor — 빈 컨테이너 첫 자식 삽입 + 폼 컴포넌트 propControl 편집.
|
|
*
|
|
* 결함#1 (빈 컨테이너 첫 자식 삽입 accepts 오필터):
|
|
* - "+ 요소 추가" 글로벌 팔레트는 선택 노드가 컨테이너이면 그 children 끝에 삽입한다.
|
|
* - 컨테이너 여부를 children 배열 존재로 판정하면(`Array.isArray(node.children)`) 자식이
|
|
* 아직 없는 빈 컨테이너(Div/Form 등)는 컨테이너로 인식되지 못해 부모 accepts 로 필터된다.
|
|
* - 수정: nesting.containers 정의 기준(isContainerComponent — 드롭 경로와 동일)으로 판정.
|
|
* 빈 Div 선택 시 팔레트가 Div 자신의 accepts(폼 컴포넌트 포함)로 필터되어야 한다.
|
|
*
|
|
* 폼 컴포넌트 propControl → 실 DOM 반영 (1-b-verify-2):
|
|
* - Input/Label 을 빈 Div 안에 추가하고 속성 탭에서 propControl 을 편집하면
|
|
* 캔버스 실 DOM 에 반영되어야 한다(Input type/placeholder, Label htmlFor).
|
|
*
|
|
* 단위(Vitest): resolveGlobalInsertionTarget(nestingRules.test.ts) 가 빈 컨테이너 판정 로직을,
|
|
* PasswordInput.test.tsx 가 data-editor-* 루트 부착을 잠근다. 본 Playwright 는 실제 편집기
|
|
* 팔레트 필터 연동 + propControl→DOM 라운드트립을 브라우저로 검증한다.
|
|
*
|
|
* @scenario empty_container_first_child + form_propcontrol_edit
|
|
* @effects empty_container_global_palette_filters_by_own_accepts_not_parent + palette_filters_by_parent_accepts
|
|
*/
|
|
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
|
|
import type { Page } from '@playwright/test';
|
|
import { editorPath as resolveEditorPath } from '../../fixtures/layout-editor';
|
|
|
|
async function gotoEditor(page: Page, route = '%2F'): Promise<void> {
|
|
const token = issueToken('core.templates.layouts.edit');
|
|
await authenticatePage(page, token);
|
|
await page.goto(`/admin/layout-editor/sirsoft-basic?route=${route}`);
|
|
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,
|
|
});
|
|
}
|
|
|
|
/** 합성 pointer/click 으로 노드 선택 → overlay info 버튼 등장 대기. */
|
|
async function selectByPath(page: Page, path: string): Promise<boolean> {
|
|
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 + Math.min(r.width / 2, 10);
|
|
const cy = r.top + Math.min(r.height / 2, 10);
|
|
for (const type of ['pointerover', 'pointermove', 'pointerdown', 'pointerup', 'click']) {
|
|
el.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX: cx, clientY: cy }));
|
|
}
|
|
}, path);
|
|
return page
|
|
.waitForSelector('[data-testid="g7le-overlay-info-button"]', { timeout: 5_000 })
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
}
|
|
|
|
/** "+ 요소 추가" 팔레트 열고 표시된 항목 이름 목록 반환. */
|
|
async function openPaletteItems(page: Page): Promise<string[]> {
|
|
await page.waitForFunction(
|
|
() => !document.querySelector('[data-testid="g7le-toolbar-add-element"]')?.hasAttribute('disabled'),
|
|
{ timeout: 10_000 },
|
|
);
|
|
await page.getByTestId('g7le-toolbar-add-element').click();
|
|
await page.waitForSelector('[data-testid^="g7le-palette-item-"]', { timeout: 10_000 });
|
|
return page.evaluate(() =>
|
|
Array.from(document.querySelectorAll('[data-testid^="g7le-palette-item-"]'))
|
|
.map((e) => (e.getAttribute('data-testid') ?? '').replace('g7le-palette-item-', ''))
|
|
.filter((n) => n && !n.endsWith('-tag') && !n.endsWith('-badge')),
|
|
);
|
|
}
|
|
|
|
async function clickPaletteItem(page: Page, name: string): Promise<void> {
|
|
await page.getByTestId(`g7le-palette-item-${name}`).click();
|
|
await page.waitForTimeout(400);
|
|
}
|
|
|
|
/** 검증 컨테이너(빈 Div) 를 home content root 안에 추가하고 그 path 를 반환. */
|
|
async function addEmptyDiv(page: Page): Promise<string> {
|
|
// 첫 세그먼트는 베이스 레이아웃 루트 인덱스라 베이스에 컴포넌트가 추가되면 밀린다 —
|
|
// 리터럴 대신 본문 루트 id 로 해석한다.
|
|
const contentRoot = await resolveEditorPath(page, 'children.5.children.0.children.0');
|
|
expect(await selectByPath(page, contentRoot)).toBe(true);
|
|
await openPaletteItems(page);
|
|
await clickPaletteItem(page, 'Div');
|
|
return page.evaluate((C) => {
|
|
const re = new RegExp('^' + C.replace(/\./g, '\\.') + '\\.children\\.\\d+$');
|
|
const idxs = Array.from(document.querySelectorAll('[data-editor-path]'))
|
|
.map((e) => e.getAttribute('data-editor-path') ?? '')
|
|
.filter((p) => re.test(p))
|
|
.map((p) => parseInt(p.split('.').pop() as string, 10));
|
|
return C + '.children.' + Math.max(...idxs);
|
|
}, contentRoot);
|
|
}
|
|
|
|
test.describe('@layout-editor 빈 컨테이너 첫 자식 + 폼 propControl', () => {
|
|
test('빈 Div 선택 시 팔레트가 그 Div 의 accepts(폼 포함)로 필터된다 (결함#1)', async ({ page }) => {
|
|
await gotoEditor(page);
|
|
|
|
const vdiv = await addEmptyDiv(page);
|
|
// 빈 Div 선택 (children 없음)
|
|
expect(await selectByPath(page, vdiv)).toBe(true);
|
|
|
|
const items = await openPaletteItems(page);
|
|
// 빈 Div 자신의 accepts 로 필터 → 폼 컴포넌트가 보여야 함 (수정 전엔 부모 Container accepts 라 미표시)
|
|
for (const form of ['Label', 'Input', 'Textarea', 'Select', 'Checkbox', 'PasswordInput', 'FileUploader', 'AvatarUploader']) {
|
|
expect(items, `빈 Div 팔레트에 ${form} 노출`).toContain(form);
|
|
}
|
|
// Div accepts(44) 수준 — 부모 Container accepts(23)보다 넓음
|
|
expect(items.length).toBeGreaterThan(30);
|
|
});
|
|
|
|
test('빈 Div 에 Input/Label 추가 후 propControl 편집이 캔버스 DOM 에 반영', async ({ page }) => {
|
|
await gotoEditor(page);
|
|
const vdiv = await addEmptyDiv(page);
|
|
|
|
// Input 추가
|
|
expect(await selectByPath(page, vdiv)).toBe(true);
|
|
await openPaletteItems(page);
|
|
await clickPaletteItem(page, 'Input');
|
|
const inputPath = vdiv + '.children.0';
|
|
|
|
// Input 속성 탭 → inputType=email, placeholder 편집
|
|
expect(await selectByPath(page, inputPath)).toBe(true);
|
|
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-tab-props"]', { timeout: 10_000 });
|
|
await page.getByTestId('g7le-property-tab-props').click();
|
|
|
|
// inputType select → email
|
|
await page.evaluate(() => {
|
|
const ctrl = document.querySelector('[data-testid="g7le-control-inputType"]');
|
|
const sel = ctrl?.querySelector('select') as HTMLSelectElement | null;
|
|
if (sel) {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!;
|
|
setter.call(sel, 'email');
|
|
sel.dispatchEvent(new Event('input', { bubbles: true }));
|
|
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
});
|
|
await page.waitForTimeout(250);
|
|
|
|
// 캔버스 Input 의 type 속성이 email 로 반영
|
|
const inputType = await page.evaluate(
|
|
(p) => document.querySelector(`[data-editor-path="${p}"]`)?.getAttribute('type'),
|
|
inputPath,
|
|
);
|
|
expect(inputType).toBe('email');
|
|
});
|
|
});
|