Files
Gnuboard7/tests/Playwright/specs/layout-editor/array-editor.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

290 lines
14 KiB
TypeScript

/**
* Layout Editor — array 노드 에디터(빌트인 ARRAY-PROP).
*
* TabNavigation 등 배열 prop 컴포넌트는 capability `nodeEditor:{kind:"array",params:{arrayProp,
* fields,...}}` 로 props 배열(tabs/items/columns 등)을 속성 모달 [속성] 탭에서 항목 단위로
* 편집한다. 코어 빌트인 ArrayItemsEditor 가 registerCoreEditors 의 registerNodeEditor('array', ...)
* 로 일반 레지스트리에 등록되어, PropertyEditorModal 이 kind(컴포넌트명 아님)로 디스패치한다.
*
* 본 E2E 는 라이브 편집기에서:
* 1. TabNavigation 을 추가하고 선택 → 속성 모달의 array 에디터(g7le-array-editor)가 마운트되는지,
* 2. 항목 추가가 행(g7le-array-row-N)에 반영되는지,
* 3. 필드 편집(id text 필드)이 즉시 반영되는지,
* 4. 저장(PUT 200),
* 을 검증한다. 추가/삭제/정렬/필드편집/디그레이드 회귀는 단위(ArrayItemsEditor.test.tsx)가
* 잠그고, 본 E2E 는 라이브 반영·저장을 브라우저로 확인한다.
*
* @scenario array_node_editor + add_item + edit_text_field + live_persist
* @effects property_modal_dispatches_array_node_editor_in_props_tab_by_kind, add_item_appends_newitem_skeleton_patches_whole_node, text_field_updates_item_immediately, live_add_tab_edit_label_reorder_save_persists_to_user_page, editor_save_specs_target_sandbox_layout_not_product_layout
*/
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
import type { Page } from '@playwright/test';
import { SANDBOX_ROOT_ID, sandboxRouteParam } from '../../fixtures/seed-layout';
import { editorPath } 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,
});
}
async function selectByPath(page: Page, path: string, timeout = 5_000): 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 })
.then(() => true)
.catch(() => false);
}
async function openPropsTab(page: Page): Promise<void> {
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();
await page.waitForTimeout(200);
}
/**
* content root(Div) 안에 TabNavigation 을 추가하고 그 path 반환.
*
* @param page Playwright page
* @param sandbox true 면 시드 화면의 전용 컨테이너(고정 id)를 대상으로 한다 (저장 spec 전용).
*/
async function addTabNavigation(page: Page, sandbox = false): Promise<string> {
if (sandbox) {
const root = await editorPath(page, '', SANDBOX_ROOT_ID);
expect(await selectByPath(page, root)).toBe(true);
return appendTabNavigationTo(page, root);
}
// 편집 가능한(ⓘ 가 뜨는) 컨테이너 Div 후보 순회 — 첫 Div 고정 선택은 루트/베이스 잠금
// 노드가 첫 후보가 되는 레이아웃 구조 변화에 깨진다(children-list-editor.spec 와 동일 패턴).
const candidates = await page.evaluate(() =>
Array.from(document.querySelectorAll('[data-editor-name="Div"]'))
.filter((e) => {
const r = e.getBoundingClientRect();
return r.width > 40 && r.height > 20;
})
.map((e) => e.getAttribute('data-editor-path') ?? '')
.filter(Boolean)
.slice(0, 10),
);
expect(candidates.length).toBeGreaterThan(0);
let containerPath: string | null = null;
for (const cand of candidates) {
if (await selectByPath(page, cand, 2_000)) {
containerPath = cand;
break;
}
}
expect(containerPath).toBeTruthy();
return appendTabNavigationTo(page, containerPath!);
}
/**
* 선택된 컨테이너에 팔레트로 TabNavigation 을 추가하고 새 노드의 path 를 반환합니다.
*
* @param page Playwright page
* @param containerPath 선택 완료된 컨테이너의 editor path
*/
async function appendTabNavigationTo(page: Page, containerPath: string): Promise<string> {
await page.getByTestId('g7le-toolbar-add-element').click();
await page.waitForSelector('[data-testid="g7le-palette-item-TabNavigation"]', { timeout: 10_000 });
await page.getByTestId('g7le-palette-item-TabNavigation').click();
await page.waitForTimeout(400);
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);
}, containerPath);
}
test.describe('@layout-editor array 노드 에디터(빌트인 ARRAY-PROP)', () => {
test('TabNavigation 선택 시 array 에디터가 속성 탭에 마운트되고 항목 추가가 행 반영', async ({ page }) => {
await gotoEditor(page);
const navPath = await addTabNavigation(page);
expect(await selectByPath(page, navPath)).toBe(true);
await openPropsTab(page);
await expect(page.getByTestId('g7le-array-editor')).toBeVisible();
const rowsBefore = await page.locator('[data-testid^="g7le-array-row-"]').count();
await page.getByTestId('g7le-array-add').click();
await page.waitForTimeout(300);
const rowsAfter = await page.locator('[data-testid^="g7le-array-row-"]').count();
expect(rowsAfter).toBe(rowsBefore + 1);
});
test('항목 id text 필드 편집이 즉시 반영(입력값 유지)', async ({ page }) => {
await gotoEditor(page);
const navPath = await addTabNavigation(page);
expect(await selectByPath(page, navPath)).toBe(true);
await openPropsTab(page);
await expect(page.getByTestId('g7le-array-editor')).toBeVisible();
// 항목이 없으면 추가.
if ((await page.locator('[data-testid^="g7le-array-row-"]').count()) === 0) {
await page.getByTestId('g7le-array-add').click();
await page.waitForTimeout(300);
}
const idField = page.getByTestId('g7le-array-field-0-id');
await idField.fill('home');
await expect(idField).toHaveValue('home');
});
// 저장(PUT)하는 테스트는 편집 결과가 그대로 영속되므로 제품 화면(home)이 아니라 E2E 전용
// 시드 화면(e2e_sandbox)을 대상으로 한다. 이전에는 "추가한 TabNavigation 삭제 후 재저장" 으로
// 원복했으나, 원복 자체가 또 한 번의 저장이라 실패하면 잔여물이 남았다. 시드 화면은 globalSetup 이
// 매 실행 fixture 원본으로 덮어쓰므로 원복 절차가 필요 없다.
test('array 편집 후 저장 → PUT 200', async ({ page }) => {
test.setTimeout(60_000); // 저장 + 모달 닫힘 대기 합산 — 기본 30s 부족
await gotoEditor(page, sandboxRouteParam());
const navPath = await addTabNavigation(page, true);
expect(await selectByPath(page, navPath)).toBe(true);
await openPropsTab(page);
await expect(page.getByTestId('g7le-array-editor')).toBeVisible();
await page.getByTestId('g7le-array-add').click();
await page.waitForTimeout(300);
// 모달을 확실히 닫은 뒤(미닫힘 상태 role 매칭은 모달 안 버튼이 잡히는 flake)
// 툴바 저장 testid 클릭 (children-list-editor.spec 와 동일 패턴).
await page
.getByTestId('g7le-property-modal-done')
.click({ timeout: 2_000 })
.catch(() => undefined);
await page
.getByRole('button', { name: /^(닫기|Close)$/ })
.first()
.click({ timeout: 3_000 })
.catch(() => undefined);
await page.waitForTimeout(300);
const savePromise = page.waitForResponse(
(r) =>
/\/api\/admin\/templates\/sirsoft-basic\/layouts\//.test(r.url()) &&
r.request().method() === 'PUT',
{ timeout: 15_000 },
);
await page.getByTestId('g7le-toolbar-save').click();
const saveRes = await savePromise;
expect(saveRes.status()).toBe(200);
// 저장한 노드가 캔버스에 남아 있어야 한다(저장이 문서를 되돌리지 않았음).
const persisted = await page.evaluate(
(p) => !!document.querySelector(`[data-editor-path="${p}"]`),
navPath,
);
expect(persisted, '저장 후 추가한 TabNavigation 이 캔버스에서 사라졌다').toBe(true);
});
// array kind `defaultItems` 시드.
// IconSelect 는 options 미지정 시 내장 기본 아이콘 20종으로 렌더한다. 종전에는 옵션
// 편집이 빈 목록에서 시작해 1개 추가가 내장 목록 전체를 교체하는 함정이었다. 스펙
// params.defaultItems 선언으로 에디터가 기본 20종을 시작 목록으로 보여주고, 추가 시
// 전체+추가분이 함께 커밋된다.
test('IconSelect 옵션 에디터 — defaultItems 20종 시드 + 추가 시 기본 목록 보존', async ({ page }) => {
// 관리자 템플릿 편집기(IconSelect 는 admin 전용 컴포넌트).
const token = issueToken('core.templates.layouts.edit');
await authenticatePage(page, token);
await page.goto('/admin/layout-editor/sirsoft-admin_basic?route=*%2Fadmin%2Fdashboard');
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 },
);
// 가시 노드 선택 → 아래 삽입 팔레트 → IconSelect 추가.
// 앵커는 "아래에 삽입할 자리"를 잡기 위한 것일 뿐 특정 컴포넌트가 주제가 아니다. 예전에는
// BarChart 를 지목했지만 그 노드는 캔버스에서 선택되지 않는다(조상 Div 가 선택됨) —
// 특정 이름에 묶지 않고 **실제로 선택되는** 가시 노드를 앵커로 쓴다.
const candidates = await page.evaluate(() =>
Array.from(document.querySelectorAll('[data-editor-path]'))
.filter((e) => {
const r = e.getBoundingClientRect();
return r.width > 0 && r.height > 0;
})
.map((e) => e.getAttribute('data-editor-path') ?? '')
.filter(Boolean),
);
expect(candidates.length, '가시 편집 노드가 있어야 함').toBeGreaterThan(0);
// 선택되는 것만으로는 부족하다 — 그 노드에 "아래 삽입"(+) 어포던스가 실제로 떠야 한다
// (루트 컨테이너 등은 형제 삽입 자리가 없어 어포던스가 없다).
let anchorPath: string | null = null;
for (const candidate of candidates.slice(0, 20)) {
if (!(await selectByPath(page, candidate, 1_500))) continue;
const hasBelow = await page
.waitForSelector('[data-testid="g7le-insertion-below"]', { state: 'visible', timeout: 1_500 })
.then(() =>
// 어포던스가 보이더라도 잠금 등으로 비활성(`data-disabled="true"`)이면 클릭할 수 없다.
page.evaluate(() => {
const b = document.querySelector('[data-testid="g7le-insertion-below"]');
return !!b && b.getAttribute('data-disabled') !== 'true' && !b.hasAttribute('disabled');
}),
)
.catch(() => false);
if (hasBelow) {
anchorPath = candidate;
break;
}
}
expect(anchorPath, '아래 삽입 어포던스가 뜨는 선택 가능 노드를 찾지 못했다').toBeTruthy();
await page.getByTestId('g7le-insertion-below').click();
await page.waitForSelector('[data-testid="g7le-palette-item-IconSelect"]', { timeout: 10_000 });
await page.getByTestId('g7le-palette-item-IconSelect').click();
await page.waitForTimeout(600);
const nodePath = await page.evaluate(() => {
const els = Array.from(document.querySelectorAll('[data-editor-name="IconSelect"]'));
return els[els.length - 1]?.getAttribute('data-editor-path') ?? null;
});
expect(nodePath).toBeTruthy();
expect(await selectByPath(page, nodePath!)).toBe(true);
await openPropsTab(page);
// 시드: 기본 20종이 시작 목록으로 표시(빈 목록 아님).
await expect(page.getByTestId('g7le-array-editor')).toBeVisible();
const rowsOf = () =>
page.evaluate(() => document.querySelectorAll('[data-testid^="g7le-array-row-"]').length);
expect(await rowsOf()).toBe(20);
// 추가 → 21행, 기본 목록 보존(첫 항목 유지).
await page.getByTestId('g7le-array-add').click();
await page.waitForTimeout(300);
const rows1 = await rowsOf();
expect(rows1).toBe(21);
await expect(page.locator('[data-testid="g7le-array-row-0"] input').first()).toHaveValue(
'LayoutDashboard',
);
// 정리(저장 안 함) — 추가 행 삭제로 잔여 0.
await page.getByTestId(`g7le-array-remove-${rows1 - 1}`).click();
await page.waitForTimeout(300);
expect(await rowsOf()).toBe(20);
});
});