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

210 lines
10 KiB
TypeScript

/**
* Layout Editor — table 노드 에디터(빌트인 STRUCT-TREE).
*
* Table 은 capability `nodeEditor:{kind:"table",params:{rowContainer/row/cell/...}}` 로
* 표 구조(섹션>행>셀)를 속성 모달 [속성] 탭에서 편집한다. 코어 빌트인 TableEditor 가
* registerCoreEditors 의 registerNodeEditor('table', ...) 로 일반 레지스트리에 등록되어,
* PropertyEditorModal 이 kind(컴포넌트명 아님)로 디스패치한다.
*
* 본 E2E 는 라이브 편집기에서:
* 1. Table 을 추가하고 선택 → 속성 모달의 table 에디터(g7le-table-editor)가 마운트되는지,
* 2. 행/열 추가가 grid(g7le-table-editor-grid)에 반영되는지,
* 3. 셀 선택 + Shift 영역 선택 → 병합 버튼 활성,
* 4. 저장(PUT 200) → reload 영속,
* 을 검증한다. 행/열/병합/해제/테두리/셀텍스트 round-trip 은 단위(tableGridModel.test.ts /
* TableEditor.test.tsx)가 잠그고, 본 E2E 는 구조 편집의 라이브 반영·저장을 브라우저로 확인한다.
*
* @scenario table_node_editor + add_row_col + merge + live_persist
* @effects property_modal_dispatches_table_node_editor_in_props_tab_by_kind, add_row_inserts_blank_row_keeps_col_count, add_column_inserts_blank_col, shift_select_range_then_merge_sets_origin_span_removes_absorbed, live_add_row_col_merge_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): 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 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);
}
/**
* 편집기에서 **실제로 선택되는** Div 컨테이너를 찾아 그 path 를 반환합니다.
*
* 캔버스의 첫 Div 는 잠금 영역이나 래퍼라 선택되지 않는 경우가 있다. 순번으로 지목하지 않고
* 선택 성공 여부로 고른다.
*
* @param page Playwright page
* @returns 선택에 성공한 Div 의 editor path (없으면 null)
*/
async function pickSelectableDiv(page: Page): Promise<string | null> {
const candidates = await page.evaluate(() =>
Array.from(document.querySelectorAll('[data-editor-name="Div"]'))
.map((e) => e.getAttribute('data-editor-path') ?? '')
.filter(Boolean),
);
for (const candidate of candidates.slice(0, 15)) {
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 }));
}
}, candidate);
// 선택 판정은 선택 박스(`g7le-overlay-selected`)로 한다. ⓘ 버튼은 큰 루트 컨테이너에서
// 뷰포트 밖에 놓일 수 있어 판정 기준으로 부적합하다 (table-inplace-overlay spec 의 실측 주석과 동일).
const selected = await page
.waitForSelector('[data-testid="g7le-overlay-selected"]', { timeout: 1_200 })
.then(() => true)
.catch(() => false);
if (selected) return candidate;
}
return null;
}
/**
* content root(Div) 안에 Table 을 추가하고 그 path 반환.
*
* 삽입 위치는 선택 컨테이너에 따라 정해지므로 children 인덱스를 계산하지 않고 추가 전후
* Table 노드 집합의 차이로 새 노드를 찾는다.
*
* @param page Playwright page
* @param sandbox true 면 시드 화면의 전용 컨테이너를 대상으로 한다 (저장 spec 전용).
* 제품 화면에서는 어느 Div 가 선택 가능한지 정해져 있지 않아 후보를 순회하지만,
* 시드 화면은 컨테이너가 고정 id 라 곧바로 지목한다.
*/
async function addTable(page: Page, sandbox = false): Promise<string> {
const containerPath = sandbox
? await editorPath(page, '', SANDBOX_ROOT_ID)
: await pickSelectableDiv(page);
expect(containerPath, '선택 가능한 Div 컨테이너를 찾지 못했다').toBeTruthy();
const tables = () =>
page.evaluate(() =>
Array.from(document.querySelectorAll('[data-editor-name="Table"]'))
.map((e) => e.getAttribute('data-editor-path') ?? '')
.filter(Boolean),
);
const before = new Set(await tables());
await page.getByTestId('g7le-toolbar-add-element').click();
await page.waitForSelector('[data-testid="g7le-palette-item-Table"]', { timeout: 10_000 });
await page.getByTestId('g7le-palette-item-Table').click();
await page.waitForTimeout(600);
const fresh = (await tables()).filter((p) => !before.has(p));
expect(fresh.length, '추가한 Table 노드를 캔버스에서 찾지 못했다').toBeGreaterThan(0);
return fresh.sort()[fresh.length - 1];
}
test.describe('@layout-editor table 노드 에디터(빌트인 STRUCT-TREE)', () => {
test('Table 선택 시 table 에디터가 속성 탭에 마운트되고 행/열 추가가 grid 반영', async ({ page }) => {
await gotoEditor(page);
const tablePath = await addTable(page);
expect(await selectByPath(page, tablePath)).toBe(true);
await openPropsTab(page);
await expect(page.getByTestId('g7le-table-editor')).toBeVisible();
await expect(page.getByTestId('g7le-table-editor-grid')).toBeVisible();
// 행 수(거터 기준) 측정 → 행 추가 → 증가 확인.
const rowsBefore = await page.locator('[data-testid^="g7le-table-rowgutter-"]').count();
await page.getByTestId('g7le-table-add-row-bottom').click();
await page.waitForTimeout(300);
const rowsAfter = await page.locator('[data-testid^="g7le-table-rowgutter-"]').count();
expect(rowsAfter).toBe(rowsBefore + 1);
// 열 추가 → 열 거터 증가.
// 거터 안의 버튼들은 `visibility: hidden` 이고 그 거터에 hover 했을 때만 보인다 —
// hover 없이 클릭하면 actionable 하지 않아 타임아웃한다.
const colsBefore = await page.locator('[data-testid^="g7le-table-colgutter-"]').count();
await page.getByTestId('g7le-table-colgutter-0').hover();
await page.getByTestId('g7le-table-col-add-0').click();
await page.waitForTimeout(300);
const colsAfter = await page.locator('[data-testid^="g7le-table-colgutter-"]').count();
expect(colsAfter).toBe(colsBefore + 1);
});
test('셀 선택 + Shift 영역 선택 → 병합 버튼 활성 → 병합 반영', async ({ page }) => {
await gotoEditor(page);
const tablePath = await addTable(page);
expect(await selectByPath(page, tablePath)).toBe(true);
await openPropsTab(page);
await expect(page.getByTestId('g7le-table-editor')).toBeVisible();
// 병합 버튼은 단일 선택 시 비활성.
await page.getByTestId('g7le-table-cell-0-0').click();
await expect(page.getByTestId('g7le-table-merge')).toBeDisabled();
// Shift 로 영역 선택 → 활성.
await page.getByTestId('g7le-table-cell-0-1').click({ modifiers: ['Shift'] });
await expect(page.getByTestId('g7le-table-merge')).toBeEnabled();
await page.getByTestId('g7le-table-merge').click();
await page.waitForTimeout(300);
// 병합 후 origin 셀이 colSpan 2 로 렌더(셀 1개로 줄어든 첫 행).
await expect(page.getByTestId('g7le-table-cell-0-0')).toBeVisible();
});
// 저장(PUT)하는 테스트는 편집 결과가 그대로 영속되므로 제품 화면(home)이 아니라 E2E 전용
// 시드 화면(e2e_sandbox)을 대상으로 한다. 제품 화면에 저장하면 실행마다 빈 표가 누적된다
// (실측: 7개, 20,321 → 33,696 bytes). 시드 화면은 globalSetup 이 매 실행 원본으로 덮어쓴다.
test('table 편집 후 저장 → PUT 200', async ({ page }) => {
await gotoEditor(page, sandboxRouteParam());
const tablePath = await addTable(page, true);
expect(await selectByPath(page, tablePath)).toBe(true);
await openPropsTab(page);
await page.getByTestId('g7le-table-add-row-bottom').click();
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-property-modal-done').click().catch(() => undefined);
await page.getByRole('button', { name: /save|저장/i }).first().click();
const saveRes = await savePromise;
expect(saveRes.status()).toBe(200);
});
});