From 0f0ef8fa8d178fb2c92eb4438c50d626b90a59b8 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Thu, 30 Jul 2026 23:46:46 +0900 Subject: [PATCH] =?UTF-8?q?test(e2e):=20=ED=8E=B8=EC=A7=91=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20spec=20=EC=A0=84=EC=9A=A9=20=EC=8B=9C?= =?UTF-8?q?=EB=93=9C=20=ED=99=94=EB=A9=B4=20=EB=B6=84=EB=A6=AC=20+=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=2010=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 레이아웃 편집기 spec 중 저장(PUT)까지 수행하는 것들이 제품 화면에 편집 결과를 영속시켜, 실행할 때마다 개발 사이트에 노드가 누적됐다(home 에 빈 표 7개, 관리자 대시보드에 빈 차트 5개). spec 안에 "추가한 노드 삭제 후 재저장" 원복을 넣어도 소용이 없었다 — 원복 후에도 레이아웃이 오염 시점과 정확히 같은 크기로 되돌아왔다. 원복에 기대는 대신 저장 대상 자체를 E2E 전용 시드 화면으로 분리한다. 시드 화면은 활성 템플릿 디렉토리에만 설치되므로 배포 원본과 릴리스 산출물은 변하지 않는다. globalSetup 이 매 실행 fixture 원본으로 덮어써 회차 간 누적이 성립하지 않으므로 저장 spec 에 원복 절차가 필요 없다. 설치는 레이아웃 파일과 라우트, DB 행 세 가지를 함께 다루되 시드 행만 건드린다 — 전체 재동기화를 쓰면 편집기로 저장한(파일에 없는) 변경이 실행할 때마다 사라지기 때문이다. 전수 실행에서 드러난 기존 실패도 함께 처리했다. 35건 중 27건은 절전·부하 아티팩트였고 나머지는 원인을 확정해 고쳤다. 템플릿 식별자 자리에 모듈 id 를 넣어 라우트 트리가 비어 있었고, "캔버스 텍스트가 길어진다"는 대리 지표가 렌더 여부와 무관했으며(정상 렌더에도 짧아진다), 팔레트 삽입 위치가 잠금 서브트리 안이면 선택이 조상으로 밀려 노드를 지목할 수 없었고, 상태 개수 단언이 상태 추가로 낡았고, if 게이트상 그 탭에 등록되지 않는 소스를 refetch 하고 있었고, 무통신 구간이 오지 않는 관리자 화면에 networkidle 을 기다렸고, 테스트 종료 후 도착한 route 콜백이 워커를 중단시켰고, 같은 설정 화면에 저장하는 테스트들이 병렬로 충돌했다. 측정 방법에서 반복해 틀린 지점들은 E2E 가이드에 표로 남겼다 — 가림은 가시성 판정으로 잡히지 않고, 길이 증감은 렌더 신호가 아니며, 총 개수 단언은 스펙이 늘면 깨지고, networkidle 은 실시간 연결이 있는 화면에서 오지 않는다. --- app/Console/Commands/PlaywrightSeedLayout.php | 377 ++++++++++++++++++ .../LayoutRepositoryInterface.php | 13 + app/Repositories/LayoutRepository.php | 22 + docs/testing/e2e-testing.md | 78 ++++ ...rder-admin-list-context-round-trip.spec.ts | 9 +- playwright.config.ts | 5 + .../PlaywrightSeedLayoutCommandTest.php | 353 ++++++++++++++++ tests/Playwright/fixtures/layout-editor.ts | 68 ++++ tests/Playwright/fixtures/seed-layout.ts | 91 +++++ .../sirsoft-admin_basic.e2e_sandbox.json | 58 +++ .../sirsoft-basic.e2e_sandbox.json | 69 ++++ tests/Playwright/global-setup.ts | 13 + tests/Playwright/global-teardown.ts | 14 + .../specs/admin/settings-info-opcache.spec.ts | 20 +- ...settings-sitemap-generation-fields.spec.ts | 145 ++++++- .../action-flex-condition.spec.ts | 27 +- .../layout-editor/action-reorder-drag.spec.ts | 76 ++-- .../specs/layout-editor/array-editor.spec.ts | 105 +++-- .../children-list-editor.spec.ts | 66 ++- .../condition-preset-variant-defense.spec.ts | 14 +- .../specs/layout-editor/data-builtins.spec.ts | 188 +++++++-- .../empty-container-form-propcontrols.spec.ts | 5 +- .../layout-editor/inline-edit-i18n.spec.ts | 13 +- .../iteration-edit-save-return-inline.spec.ts | 21 +- .../locked-area-insertion.spec.ts | 25 +- .../layout-editor/page-state-switcher.spec.ts | 27 +- .../palette-i18n-and-globals.spec.ts | 8 +- .../prop-i18n-text-field.spec.ts | 36 +- ...property-editor-new-style-controls.spec.ts | 67 +++- .../property-editor-s7-followup.spec.ts | 16 +- .../property-editor-scope.spec.ts | 10 +- .../sample-data-canvas-render.spec.ts | 4 +- .../sample-gate-body-render.spec.ts | 27 +- ...scroll-and-extension-lock-followup.spec.ts | 21 +- .../layout-editor/separate-edit-modes.spec.ts | 33 +- .../specs/layout-editor/table-editor.spec.ts | 103 ++++- .../table-inplace-overlay.spec.ts | 83 +++- .../layout-editor/tabnav-inplace.spec.ts | 4 +- .../production-sourcemap-exposure.spec.ts | 28 ++ ...policy-scope-no-language-pack-leak.spec.ts | 6 +- ...linit-multi-progressive-datasource.spec.ts | 43 +- .../specs/smoke/network-resilience.spec.ts | 18 +- .../LayoutRepositoryDeleteByNameTest.php | 84 ++++ .../layout-editor-e2e-seed-layout.yaml | 54 +++ 44 files changed, 2299 insertions(+), 248 deletions(-) create mode 100644 app/Console/Commands/PlaywrightSeedLayout.php create mode 100644 tests/Feature/Console/PlaywrightSeedLayoutCommandTest.php create mode 100644 tests/Playwright/fixtures/layout-editor.ts create mode 100644 tests/Playwright/fixtures/seed-layout.ts create mode 100644 tests/Playwright/fixtures/seed-layouts/sirsoft-admin_basic.e2e_sandbox.json create mode 100644 tests/Playwright/fixtures/seed-layouts/sirsoft-basic.e2e_sandbox.json create mode 100644 tests/Playwright/global-setup.ts create mode 100644 tests/Playwright/global-teardown.ts create mode 100644 tests/Unit/Repositories/LayoutRepositoryDeleteByNameTest.php create mode 100644 tests/scenarios/layout-editor-e2e-seed-layout.yaml diff --git a/app/Console/Commands/PlaywrightSeedLayout.php b/app/Console/Commands/PlaywrightSeedLayout.php new file mode 100644 index 00000000..812a3599 --- /dev/null +++ b/app/Console/Commands/PlaywrightSeedLayout.php @@ -0,0 +1,377 @@ + + */ + private const DEFAULT_TARGETS = [ + 'sirsoft-basic' => '/e2e-sandbox', + 'sirsoft-admin_basic' => '*/admin/e2e-sandbox', + ]; + + public function __construct( + private LayoutRepositoryInterface $layoutRepository, + private TemplateRepositoryInterface $templateRepository + ) { + parent::__construct(); + } + + /** + * 커맨드를 실행합니다. + * + * @return int 종료 코드 (0 성공) + */ + public function handle(): int + { + // ① CLI 한정 — production 웹 요청에서 절대 도달 불가 + if (php_sapi_name() !== 'cli') { + $this->error('CLI 전용 커맨드입니다. (현재 SAPI: '.php_sapi_name().')'); + + return self::FAILURE; + } + + // ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단 + if (env('G7_PLAYWRIGHT_BYPASS') !== '1') { + $this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')'); + + return self::FAILURE; + } + + // settings JSON 이 debug 를 덮어써도 시드 작업이 막히지 않도록 인라인 강제 + // (PlaywrightIssueToken 과 동일 근거 — bypass flag 인지 시 SettingsServiceProvider 는 이미 우회 상태). + Config::set('app.debug', true); + + $remove = (bool) $this->option('remove'); + $targets = $this->resolveTargets(); + + if ($targets === []) { + $this->error('대상 템플릿이 없습니다.'); + + return self::FAILURE; + } + + foreach ($targets as $identifier => $routePath) { + if (! $this->processTemplate($identifier, $routePath, $remove)) { + return self::FAILURE; + } + } + + return self::SUCCESS; + } + + /** + * `--template` 옵션을 기본 대상 목록과 대조해 처리 대상을 결정합니다. + * + * @return array identifier => 라우트 경로 + */ + private function resolveTargets(): array + { + $requested = $this->option('template') ?: []; + + if ($requested === []) { + return self::DEFAULT_TARGETS; + } + + $targets = []; + foreach ($requested as $identifier) { + if (! isset(self::DEFAULT_TARGETS[$identifier])) { + $this->warn("시드 라우트 경로가 정의되지 않은 템플릿입니다 — 건너뜁니다: {$identifier}"); + + continue; + } + $targets[$identifier] = self::DEFAULT_TARGETS[$identifier]; + } + + return $targets; + } + + /** + * 템플릿 1개에 대해 시드 레이아웃 설치 또는 제거를 수행합니다. + * + * @param string $identifier 템플릿 identifier + * @param string $routePath 시드 라우트 경로 + * @param bool $remove true 면 제거, false 면 설치 + * @return bool 성공 여부 + */ + private function processTemplate(string $identifier, string $routePath, bool $remove): bool + { + $templateDir = base_path("templates/{$identifier}"); + + // 활성 디렉토리가 없으면 그 템플릿은 설치되지 않은 것 — 조용히 건너뛴다 + // (E2E 대상 템플릿이 환경마다 다를 수 있으므로 실패로 취급하지 않는다). + if (! File::isDirectory($templateDir)) { + $this->warn("활성 템플릿 디렉토리가 없어 건너뜁니다: {$identifier}"); + + return true; + } + + $template = $this->templateRepository->findByIdentifier($identifier); + if (! $template) { + $this->warn("템플릿이 DB 에 없어 건너뜁니다: {$identifier}"); + + return true; + } + + $layoutPath = "{$templateDir}/layouts/".self::LAYOUT_NAME.'.json'; + + if ($remove) { + if (File::exists($layoutPath)) { + File::delete($layoutPath); + } + $this->removeSeedRoute($identifier, $templateDir); + $this->layoutRepository->deleteByName($template->id, self::LAYOUT_NAME); + } else { + $fixturePath = base_path( + 'tests/Playwright/fixtures/seed-layouts/'.$identifier.'.'.self::LAYOUT_NAME.'.json' + ); + if (! File::exists($fixturePath)) { + $this->error("시드 레이아웃 fixture 가 없습니다: {$fixturePath}"); + + return false; + } + + $content = json_decode((string) File::get($fixturePath), true); + if (json_last_error() !== JSON_ERROR_NONE || ! is_array($content)) { + $this->error("시드 레이아웃 fixture 파싱 실패: {$fixturePath}"); + + return false; + } + + File::copy($fixturePath, $layoutPath); + $this->addSeedRoute($identifier, $templateDir, $routePath); + + $this->layoutRepository->updateOrCreate( + ['template_id' => $template->id, 'name' => self::LAYOUT_NAME], + [ + 'content' => $content, + 'source_type' => 'template', + 'original_content_hash' => $this->computeContentHash($content), + 'original_content_size' => $this->computeContentSize($content), + ] + ); + } + + // 편집기/공개 서빙 캐시에서 시드 레이아웃 키를 지운다. 삭제 경로에서도 캐시가 남으면 + // 편집기가 stale 문서를 계속 받는다. + $this->forgetSeedLayoutCache($template->id, $identifier); + + $this->info(($remove ? '제거' : '설치')." 완료: {$identifier} ({$routePath})"); + + return true; + } + + /** + * 활성 routes.json 에 시드 라우트를 추가합니다. + * + * 재작성은 JSON 재직렬화라 원본의 주석 그룹 사이 빈 줄 같은 서식이 사라진다. 그래서 첫 + * 설치 시 원본을 `.playwright-backup` 으로 보관하고, 제거 때 그 파일을 그대로 되돌린다 + * (바이트 동일 복원). 백업이 이미 있으면 덮어쓰지 않는다 — 비정상 종료로 시드가 남은 상태의 + * routes.json 을 "원본" 으로 굳히지 않기 위함. + * + * @param string $identifier 템플릿 identifier + * @param string $templateDir 활성 템플릿 디렉토리 절대 경로 + * @param string $routePath 등록할 시드 라우트 경로 + */ + private function addSeedRoute(string $identifier, string $templateDir, string $routePath): void + { + $path = "{$templateDir}/routes.json"; + $routes = $this->readRoutes($identifier, $path); + if ($routes === null) { + return; + } + + $backupPath = $path.self::ROUTES_BACKUP_SUFFIX; + if (! File::exists($backupPath)) { + File::copy($path, $backupPath); + } + + // 마커 항목을 먼저 걷어내 반복 실행이 라우트를 중복 추가하지 않게 한다. + $entries = $this->withoutSeedRoutes($routes); + $entries[] = [ + '_marker' => self::ROUTE_MARKER, + '_comment' => 'Playwright E2E 전용 시드 화면 — playwright:seed-layout 이 설치/제거한다. 제품 라우트가 아니다.', + 'path' => $routePath, + 'layout' => self::LAYOUT_NAME, + 'auth_required' => str_starts_with($routePath, '*/admin'), + 'meta' => [ + 'title' => 'E2E Sandbox', + ], + ]; + + $routes['routes'] = $entries; + + File::put( + $path, + json_encode($routes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n" + ); + } + + /** + * 활성 routes.json 에서 시드 라우트를 제거합니다. + * + * 백업이 있으면 그것을 그대로 복원해 서식까지 원상 복구한다. 백업이 없으면(다른 경로로 시드가 + * 들어간 경우) 마커 항목만 걷어낸 재직렬화로 대체한다. + * + * @param string $identifier 템플릿 identifier + * @param string $templateDir 활성 템플릿 디렉토리 절대 경로 + */ + private function removeSeedRoute(string $identifier, string $templateDir): void + { + $path = "{$templateDir}/routes.json"; + $backupPath = $path.self::ROUTES_BACKUP_SUFFIX; + + if (File::exists($backupPath)) { + File::put($path, File::get($backupPath)); + File::delete($backupPath); + + return; + } + + $routes = $this->readRoutes($identifier, $path); + if ($routes === null) { + return; + } + + $routes['routes'] = $this->withoutSeedRoutes($routes); + + File::put( + $path, + json_encode($routes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n" + ); + } + + /** + * routes.json 을 디코드해 반환합니다 (부재/파싱 실패 시 경고 후 null). + * + * @param string $identifier 템플릿 identifier (경고 메시지용) + * @param string $path routes.json 절대 경로 + * @return array|null 디코드 결과 + */ + private function readRoutes(string $identifier, string $path): ?array + { + if (! File::exists($path)) { + $this->warn("routes.json 이 없어 라우트 처리를 건너뜁니다: {$identifier}"); + + return null; + } + + $routes = json_decode((string) File::get($path), true); + if (json_last_error() !== JSON_ERROR_NONE || ! is_array($routes)) { + $this->warn("routes.json 을 파싱할 수 없어 라우트 처리를 건너뜁니다: {$identifier}"); + + return null; + } + + return $routes; + } + + /** + * 라우트 배열에서 시드 마커가 붙은 항목을 제외해 반환합니다. + * + * @param array $routes 디코드된 routes.json + * @return array 마커 항목이 제거된 라우트 배열 + */ + private function withoutSeedRoutes(array $routes): array + { + return array_values(array_filter( + $routes['routes'] ?? [], + fn ($route) => ! is_array($route) || ($route['_marker'] ?? null) !== self::ROUTE_MARKER + )); + } + + /** + * 시드 레이아웃의 캐시 키를 삭제합니다. + * + * `InvalidatesLayoutCache::forgetLayoutCacheKeys` 는 레이아웃 객체의 + * `template_id` / `name` / `source_type` / `source_identifier` 만 읽으므로, + * 삭제 후(모델 부재) 에도 동일 형태의 경량 객체로 호출할 수 있다. + * + * @param int $templateId 템플릿 ID + * @param string $identifier 템플릿 identifier (버전 포함 공개 서빙 키에 사용) + */ + private function forgetSeedLayoutCache(int $templateId, string $identifier): void + { + $this->forgetLayoutCacheKeys( + (object) [ + 'template_id' => $templateId, + 'name' => self::LAYOUT_NAME, + 'source_type' => null, + 'source_identifier' => null, + ], + $identifier + ); + } +} diff --git a/app/Contracts/Repositories/LayoutRepositoryInterface.php b/app/Contracts/Repositories/LayoutRepositoryInterface.php index 492313eb..153ca2a8 100644 --- a/app/Contracts/Repositories/LayoutRepositoryInterface.php +++ b/app/Contracts/Repositories/LayoutRepositoryInterface.php @@ -26,6 +26,19 @@ interface LayoutRepositoryInterface */ public function findByName(int $templateId, string $name): ?TemplateLayout; + /** + * 특정 레이아웃을 영구 삭제 (템플릿 ID와 이름으로) + * + * soft delete 가 아닌 `forceDelete` 다. 이름으로 재등록될 수 있는 레이아웃 + * (파일 → DB 동기화 대상) 은 soft delete 잔여 행이 남으면 재등록이 충돌하므로 + * 파일 기준 동기화 경로와 동일하게 영구 삭제한다. + * + * @param int $templateId 템플릿 ID + * @param string $name 레이아웃 이름 + * @return bool 삭제 여부 (대상 부재 시 false) + */ + public function deleteByName(int $templateId, string $name): bool; + /** * ID로 레이아웃 조회 * diff --git a/app/Repositories/LayoutRepository.php b/app/Repositories/LayoutRepository.php index 46eba571..67cf71c9 100644 --- a/app/Repositories/LayoutRepository.php +++ b/app/Repositories/LayoutRepository.php @@ -37,6 +37,28 @@ class LayoutRepository implements LayoutRepositoryInterface ->first(); } + /** + * 특정 레이아웃을 영구 삭제 (템플릿 ID와 이름으로) + * + * @param int $templateId 템플릿 ID + * @param string $name 레이아웃 이름 + * @return bool 삭제 여부 (대상 부재 시 false) + */ + public function deleteByName(int $templateId, string $name): bool + { + $layout = TemplateLayout::where('template_id', $templateId) + ->where('name', $name) + ->first(); + + if (! $layout) { + return false; + } + + $layout->forceDelete(); + + return true; + } + /** * ID로 레이아웃 조회 * diff --git a/docs/testing/e2e-testing.md b/docs/testing/e2e-testing.md index 685d54d7..9b0cd666 100644 --- a/docs/testing/e2e-testing.md +++ b/docs/testing/e2e-testing.md @@ -9,6 +9,7 @@ - 로케일: 모든 config 에 use.locale = 'ko-KR' 고정 (미지정 시 Accept-Language 가 en-US 로 나가 화면이 영어로 렌더 → 한국어 단언 전멸) - 코어/확장 분리: 코어 = tests/Playwright/, 확장 = {확장 디렉토리}/tests/Playwright/ - 데이터 생성: 데이터를 소유한 영역에 시드 커맨드 배치 (코어 ↔ 모듈 의존 역전 회피) +- 편집기 저장 spec: 제품 화면 대신 전용 시드 화면(e2e_sandbox) 대상 — globalSetup 이 매 실행 원본으로 덮어씀 (§4.1) - 외부 의존: mock-first 전략 (page.route() 로 결제창/외부 API 가로채기) ``` @@ -110,12 +111,14 @@ spec 이 전부 실패한다. | 레이아웃 수정 후 | 레이아웃 JSON 을 고쳤으면 `{type}:update {id} --force` **+ `template:cache-clear {템플릿}`** 까지 해야 브라우저에 반영된다. 레이아웃은 템플릿 캐시(`/api/layouts/{template}/...`)로 서빙되므로 `cache:clear` 만으로는 갱신되지 않는다 | | 공유 상태 | 같은 관리자 설정 화면을 건드리는 spec 이 병렬로 돌면 서로의 저장 상태를 덮어써 실패할 수 있다. 실행 옵션에 맡기지 않고 그 `describe` 에 `test.describe.configure({ mode: 'serial' })` 를 둔다 | | 워커 수 | 관리자 SPA 는 번들이 크고 레이아웃을 여러 번 받아온다. 개발 머신에서 2워커 이상이면 `page.waitForLoadState` 가 30초를 넘겨 **비결정적으로** 실패한다(실측: 같은 스위트가 회차마다 다른 5~7건 실패, 테스트당 8초 → 25초). 판정은 `--workers=1` 결과로 한다 | +| 편집기 spec 만 몰아 실행할 때 | 레이아웃 편집기 spec 만 골라 돌리면 워커 전부가 동시에 편집기 페이지를 연다 — 전체 스위트에서는 가벼운 spec 이 섞여 그 집중이 생기지 않는다. 실측: 편집기 7파일 27건을 7워커로 돌리면 `g7le-preview-frame` 대기가 전부 30초 타임아웃(27/27 실패), 같은 코드로 2워커는 26/27 통과. 편집기만 선택 실행할 때는 `--workers=2` 이하로 둔다 | ## §4. 데이터 생성 위치 — 책임 분리 매트릭스 | 데이터 종류 | 책임 영역 | 위치 | 호출 | |---|---|---|---| | 코어 권한/역할/유저/Sanctum 토큰 | 코어 | `app/Console/Commands/PlaywrightIssueToken.php` | `php artisan playwright:issue-token --permissions=core.xxx` | +| 편집기 저장 spec 대상 시드 화면 | 코어 | `app/Console/Commands/PlaywrightSeedLayout.php` | `php artisan playwright:seed-layout [--remove]` (globalSetup/globalTeardown 자동 호출) | | 모듈 권한 (`sirsoft-ecommerce.*`) | 모듈 | 코어 커맨드의 `--permissions=` 임의 식별자 | 동일 (Permission::firstOrCreate 자동 생성) | | 모듈 도메인 데이터 (상품/주문) | 모듈 | `modules/_bundled/{id}/src/Console/Commands/PlaywrightSeed{id}.php` | `php artisan playwright:seed-{id}` | | 플러그인 도메인 데이터 (결제 키) | 플러그인 | `plugins/_bundled/{id}/src/Console/Commands/PlaywrightSeed{id}.php` | 동일 | @@ -123,6 +126,81 @@ spec 이 전부 실패한다. **핵심 원칙**: 코어는 모듈 도메인을 모른다. 모듈 도메인 시드를 코어에 두면 의존 역전. +### 4.1 저장(PUT)하는 spec 은 제품 화면을 대상으로 두지 않는다 + +레이아웃 편집기 spec 이 저장까지 수행하면 그 편집 결과는 **그대로 영속된다**. 대상이 제품 화면 +(`home` / `admin_dashboard` 등)이면 실행할 때마다 노드가 누적돼 개발 사이트에 그대로 노출된다. + +실측(2026-07-30): `home` 에 빈 표 7개가 쌓여 20,321 → 33,696 bytes, 관리자 대시보드에 빈 +DonutChart 5개. 누적되면 캔버스 구조가 회차마다 달라져 같은 파일의 다른 테스트도 간헐 실패한다. + +spec 안에 "추가한 노드를 삭제하고 다시 저장" 원복을 넣는 것으로는 해결되지 않는다 — 원복 실행 +후에도 레이아웃이 오염 시점과 정확히 같은 크기로 되돌아왔다(편집기가 그 시점에 들고 있던 문서를 +통째로 다시 저장). 원복은 그 자체가 또 한 번의 저장이라, 실패하면 잔여물이 남는다. + +그래서 **저장 대상 자체를 전용 시드 화면으로 분리**한다. + +| 항목 | 값 | +|---|---| +| 레이아웃 이름 | `e2e_sandbox` | +| 라우트 | 사용자 템플릿 `/e2e-sandbox`, 관리자 템플릿 `*/admin/e2e-sandbox` | +| fixture 원본 | `tests/Playwright/fixtures/seed-layouts/{템플릿}.e2e_sandbox.json` | +| 설치 위치 | **활성 템플릿 디렉토리만** (`_bundled` 배포 원본 무변경, 활성 디렉토리는 Git 무시 → 릴리스 미포함) | +| 설치/제거 | `php artisan playwright:seed-layout [--remove]` (CLI + `G7_PLAYWRIGHT_BYPASS=1` 가드) | +| 자동 호출 | `globalSetup` 설치 / `globalTeardown` 제거 | +| spec 헬퍼 | `tests/Playwright/fixtures/seed-layout.ts` — `sandboxRouteParam()`, `SANDBOX_ROOT_ID` | + +설치는 3종을 함께 처리한다: 레이아웃 파일 + `routes.json` 라우트(마커 기반 멱등) + DB 행 upsert. +편집기의 라우트 트리는 `routes.json` 에서, 조회/저장은 DB 행을 대상으로 하므로 셋 중 하나만 +빠지면 `?route=` 로 열 수 없거나 404 가 된다. + +`routes.json` 은 재직렬화하면 원본의 주석 그룹 사이 빈 줄 같은 서식이 사라지므로, 설치 시 원본을 +`routes.json.playwright-backup` 으로 보관하고 제거 때 그 파일을 그대로 되돌린다(바이트 동일 복원). +백업이 이미 있으면 덮어쓰지 않는다 — 비정상 종료로 시드가 남은 상태의 파일을 "원본" 으로 굳히지 +않기 위함이다. + +시드 설치는 **시드 행 하나만** 건드린다. `template:refresh-layout`(전체 재동기화)을 쓰지 않는 +이유는 그 경로가 파일에 없는 DB 레이아웃을 지우고 모든 레이아웃을 파일 기준으로 되돌리기 +때문이다 — 편집기 UI 로 저장한 변경은 파일이 아니라 DB 에만 있으므로 E2E 를 돌릴 때마다 사람이 +편집기로 만든 결과가 사라진다. + +시드 화면은 매 실행 fixture 원본으로 덮어써지므로 회차 간 누적이 성립하지 않는다. 따라서 저장 +spec 에 원복 절차를 둘 필요가 없다. + +```typescript +import { SANDBOX_ROOT_ID, sandboxRouteParam } from '../../fixtures/seed-layout'; +import { editorPath } from '../../fixtures/layout-editor'; + +test('편집 후 저장 → PUT 200', async ({ page }) => { + await gotoEditor(page, sandboxRouteParam()); // 사용자 템플릿 + const container = await editorPath(page, '', SANDBOX_ROOT_ID); // 고정 id 컨테이너 + // ... 편집 + 저장 +}); +``` + +**저장하지 않는(읽기 전용) spec 은 계속 제품 화면을 대상으로 둔다** — 오염 위험이 없고, 실제 +제품 레이아웃에 대한 검증이 유지되는 편이 낫다. + +예외: **팔레트로 노드를 추가한 뒤 그 노드를 선택해야 하는** spec 은 저장하지 않아도 시드 화면을 +쓴다. 제품 화면에서는 삽입 위치가 "선택 가능한 Div 후보 순회" 로 정해지는데, 그 위치가 모듈이 주입한 +잠금 서브트리 안이면 선택이 조상 노드로 escalate 되어 추가한 노드를 지목할 수 없다(실측: 관리자 +대시보드에 BarChart 추가 시 오버레이 타입 라벨이 `↑Div` + ⓘ 미표시, 같은 절차를 시드 컨테이너에서 +하면 `↑BarChart` + ⓘ 표시). 시드 화면의 컨테이너는 고정 id 라 삽입 위치가 결정적이다. + +### 4.2 편집기 spec 작성 시 자주 틀리는 측정 기준 + +전수 실행에서 드러난 실패의 상당수가 제품 결함이 아니라 **측정 방법**의 문제였다. 아래는 실측으로 +확인된 것들이다. + +| 하지 말 것 | 이유 (실측) | 대신 | +|---|---|---| +| 전환 오버레이 가림을 `toBeVisible()` 로 판정 | Playwright 의 가시성 판정은 **가림(occlusion)을 보지 않는다**. 오버레이는 타겟 안/head 에 덧붙는 방식이고 콘텐츠는 DOM 에 남으므로, 덮여 있어도 visible 로 판정된다 | 오버레이 엘리먼트의 attach/detach 시각으로 측정 (`#g7-skeleton-overlay` 또는 `style#g7-transition-overlay`) | +| "캔버스 텍스트 길이가 늘어난다" 로 본체 렌더 판정 | 탭/상태를 바꾸면 **줄어들 수도** 있다. 실측: my_comments 서브탭이 정상 렌더되는데 881 → 669 로 감소(항목당 길이가 짧아서) | 그 데이터에만 있는 고유 문구 존재로 판정 | +| 상태/옵션의 **총 개수**를 단언 | 상태 그룹은 편집기 스펙에서 계속 늘어난다. 체크아웃 상태가 3 → 4 로 늘면서 `toHaveCount(3)` 이 깨졌다 | 그 테스트가 실제로 쓰는 값의 존재로 판정 | +| `waitForLoadState('networkidle')` | 관리자 SPA 는 실시간 연결·주기 폴링이 붙어 500ms 무통신 구간이 오지 않을 수 있다. 실측 30초 타임아웃 | 다음 단계에 필요한 구체 신호(클릭할 버튼의 가시성 등) | +| `/admin/layout-editor/{id}` 에 모듈/플러그인 식별자 | 그 세그먼트는 **템플릿 식별자** 자리다. 모듈 라우트는 해당 타입 템플릿 트리에 병합되므로 템플릿으로 진입해야 한다 | 템플릿 식별자 (모듈 admin 라우트 → admin 템플릿) | +| 테스트 예산과 내부 대기를 같은 값으로 | `test` 기본 예산 30초 안에서 30초 `waitForResponse` 를 걸면 앞 단계가 조금만 늦어도 구조적으로 완주 불가 | 내부 대기를 줄이거나 `test.setTimeout()` 상향 | + ## §5. fixture 패턴 ### 5.1 코어 fixture (`tests/Playwright/fixtures/auth.ts`) diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/order-admin-list-context-round-trip.spec.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/order-admin-list-context-round-trip.spec.ts index 5f2b6f81..59c52cb4 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/order-admin-list-context-round-trip.spec.ts +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/order-admin-list-context-round-trip.spec.ts @@ -36,7 +36,10 @@ test.describe('관리자 주문 목록 컨텍스트 왕복 (#75)', () => { test('주문 상세에서 목록으로 돌아오면 페이지와 표시 개수가 복원된다', async ({ page, ordersReadToken }) => { await authenticatePage(page, ordersReadToken); await page.goto(`${LIST_URL}?${LIST_STATE}`); - await page.waitForLoadState('networkidle', { timeout: 30_000 }); + // `networkidle` 은 쓰지 않는다 — 관리자 SPA 는 실시간 알림 연결·주기 폴링이 붙어 500ms 무통신 + // 구간이 오지 않을 수 있고, 실제로 주문 상세 진입에서 30초 타임아웃했다. 다음 단계에 필요한 + // 구체 신호만 기다린다(여기서는 API 를 직접 부르므로 문서 로드까지). + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); // 목록에서 상세로 들어간 직후와 동일한 URL 상태 (진입 leg 가 mergeQuery 로 만들어 주는 형태) const orderNumber = await page.evaluate(async () => { @@ -61,7 +64,9 @@ test.describe('관리자 주문 목록 컨텍스트 왕복 (#75)', () => { expect(orderNumber, '주문 시드가 없어 왕복을 검증할 수 없습니다.').not.toBeNull(); await page.goto(`${LIST_URL}/${orderNumber}?${LIST_STATE}`); - await page.waitForLoadState('networkidle', { timeout: 30_000 }); + // 다음 단계가 클릭하는 '목록' 버튼이 실제로 조작 가능해질 때까지만 기다린다. + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await expect(page.locator('#list_button')).toBeVisible({ timeout: 30_000 }); await page.locator('#list_button').click(); await page.waitForURL(/\/admin\/ecommerce\/orders\?/, { timeout: 30_000 }); diff --git a/playwright.config.ts b/playwright.config.ts index dee402db..3d9069b9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -62,6 +62,11 @@ function resolveBaseUrl(): string { export default defineConfig({ testDir: './tests/Playwright/specs', + // 레이아웃 편집기 저장(PUT) spec 전용 시드 화면 설치/제거. + // 저장 spec 이 제품 화면(home/admin_dashboard)을 대상으로 하면 실행마다 편집 결과가 + // 누적돼 개발 사이트가 오염된다 — 상세는 tests/Playwright/fixtures/seed-layout.ts. + globalSetup: './tests/Playwright/global-setup.ts', + globalTeardown: './tests/Playwright/global-teardown.ts', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, diff --git a/tests/Feature/Console/PlaywrightSeedLayoutCommandTest.php b/tests/Feature/Console/PlaywrightSeedLayoutCommandTest.php new file mode 100644 index 00000000..50130013 --- /dev/null +++ b/tests/Feature/Console/PlaywrightSeedLayoutCommandTest.php @@ -0,0 +1,353 @@ +routesPath())) { + $this->routesBackup = File::get($this->routesPath()); + } + } + + protected function tearDown(): void + { + if ($this->routesBackup !== null) { + File::put($this->routesPath(), $this->routesBackup); + } + if (File::exists($this->layoutPath())) { + File::delete($this->layoutPath()); + } + if (File::exists($this->routesBackupPath())) { + File::delete($this->routesBackupPath()); + } + $this->clearBypassFlag(); + + parent::tearDown(); + } + + /** + * @effects seed_layout_command_requires_explicit_optin_env + */ + public function test_옵트인_환경변수가_없으면_실패한다(): void + { + $this->clearBypassFlag(); + + $this->artisan('playwright:seed-layout') + ->expectsOutputToContain('G7_PLAYWRIGHT_BYPASS=1') + ->assertExitCode(1); + } + + /** + * @scenario action=install, template_in_db=yes + * + * @effects seed_layout_install_creates_file_route_and_db_row + */ + public function test_설치가_레이아웃_파일과_라우트와_d_b행을_함께_만든다(): void + { + $this->skipIfTemplateDirMissing(); + $template = $this->makeTemplate(); + + $this->runSeedCommand(); + + $this->assertTrue(File::exists($this->layoutPath()), '시드 레이아웃 파일이 생성되어야 한다'); + $this->assertCount(1, $this->seedRoutes(), 'routes.json 에 시드 라우트가 1건 있어야 한다'); + $this->assertSame('/e2e-sandbox', $this->seedRoutes()[0]['path']); + $this->assertSame(self::LAYOUT_NAME, $this->seedRoutes()[0]['layout']); + + $layout = TemplateLayout::where('template_id', $template->id) + ->where('name', self::LAYOUT_NAME) + ->first(); + $this->assertNotNull($layout, '시드 레이아웃 DB 행이 생성되어야 한다'); + $this->assertNotEmpty($layout->content, '시드 레이아웃 content 가 비어 있지 않아야 한다'); + $this->assertNotEmpty($layout->original_content_hash); + $this->assertGreaterThan(0, $layout->original_content_size); + } + + /** + * @effects seed_layout_install_is_idempotent_no_duplicate_route + */ + public function test_설치를_반복해도_라우트가_중복되지_않는다(): void + { + $this->skipIfTemplateDirMissing(); + $this->makeTemplate(); + + $this->runSeedCommand(); + $routeCountAfterFirst = count($this->allRoutes()); + + $this->runSeedCommand(); + + $this->assertCount(1, $this->seedRoutes(), '반복 실행에도 시드 라우트는 1건이어야 한다'); + $this->assertCount( + $routeCountAfterFirst, + $this->allRoutes(), + '반복 실행이 라우트 총 개수를 늘리면 안 된다' + ); + } + + /** + * @scenario action=remove, template_in_db=yes + * + * @effects seed_layout_remove_cleans_file_route_and_db_row, seed_layout_remove_keeps_other_routes + */ + public function test_제거가_파일과_라우트와_d_b행을_모두_정리한다(): void + { + $this->skipIfTemplateDirMissing(); + $template = $this->makeTemplate(); + + $this->runSeedCommand(); + $routeCountWithSeed = count($this->allRoutes()); + + $this->runSeedCommand(['--remove' => true]); + + $this->assertFalse(File::exists($this->layoutPath()), '시드 레이아웃 파일이 삭제되어야 한다'); + $this->assertCount(0, $this->seedRoutes(), 'routes.json 에서 시드 라우트가 제거되어야 한다'); + $this->assertCount( + $routeCountWithSeed - 1, + $this->allRoutes(), + '시드 라우트 1건만 줄어야 한다 (다른 라우트 무영향)' + ); + $this->assertFalse( + TemplateLayout::withTrashed() + ->where('template_id', $template->id) + ->where('name', self::LAYOUT_NAME) + ->exists(), + '시드 레이아웃 DB 행이 영구 삭제되어야 한다 (soft delete 잔여 행도 없어야 재설치가 충돌하지 않는다)' + ); + } + + /** + * @scenario action=install, template_in_db=no + * + * @effects seed_layout_skips_template_absent_from_db_without_failing + */ + public function test_d_b에_없는_템플릿은_설치를_건너뛴다(): void + { + $this->skipIfTemplateDirMissing(); + + // Template 행을 만들지 않는다 — 환경마다 설치 템플릿이 다를 수 있으므로 건너뜀이 정답. + $this->runSeedCommand(exitCode: 0); + + $this->assertFalse(File::exists($this->layoutPath()), '건너뛴 템플릿에는 파일을 만들지 않아야 한다'); + } + + /** + * @scenario action=remove, template_in_db=no + * + * @effects seed_layout_remove_skips_template_absent_from_db_without_touching_files + */ + public function test_d_b에_없는_템플릿은_제거도_건너뛴다(): void + { + $this->skipIfTemplateDirMissing(); + + // 이전 실행이 남긴 시드 파일이 있다고 가정 — DB 미등록 템플릿에서는 건드리지 않아야 한다 + // (템플릿 식별에 실패한 상태로 파일만 지우면 DB 행과 파일이 어긋난다). + File::put($this->layoutPath(), '{"layout_name":"e2e_sandbox"}'); + $routesBefore = File::get($this->routesPath()); + + $this->runSeedCommand(['--remove' => true], exitCode: 0); + + $this->assertTrue(File::exists($this->layoutPath()), 'DB 미등록 템플릿의 파일은 건드리지 않아야 한다'); + $this->assertSame($routesBefore, File::get($this->routesPath()), 'routes.json 도 변경되지 않아야 한다'); + } + + /** + * @effects seed_layout_remove_restores_routes_json_byte_for_byte + */ + public function test_제거가_routes_json_을_바이트_동일하게_복원한다(): void + { + $this->skipIfTemplateDirMissing(); + $this->makeTemplate(); + + // 원본은 주석 그룹 사이 빈 줄 같은 서식을 갖는다. JSON 재직렬화만으로는 그 서식이 사라지므로 + // 설치 시 원본을 보관하고 제거 시 그대로 되돌려야 한다. + $original = File::get($this->routesPath()); + + $this->runSeedCommand(); + $this->assertNotSame($original, File::get($this->routesPath()), '설치는 routes.json 을 바꿔야 한다'); + $this->assertTrue(File::exists($this->routesBackupPath()), '원본 백업이 남아야 한다'); + + $this->runSeedCommand(['--remove' => true]); + + $this->assertSame($original, File::get($this->routesPath()), 'routes.json 이 바이트 동일하게 복원되어야 한다'); + $this->assertFalse(File::exists($this->routesBackupPath()), '복원 후 백업 파일은 정리되어야 한다'); + } + + /** + * @effects seed_layout_repeated_install_keeps_pristine_routes_backup + */ + public function test_반복_설치가_원본_백업을_시드_포함본으로_덮어쓰지_않는다(): void + { + $this->skipIfTemplateDirMissing(); + $this->makeTemplate(); + + $original = File::get($this->routesPath()); + + // 비정상 종료로 시드가 남은 상태에서 다시 설치되는 상황 — 그 시점 routes.json 을 "원본" 으로 + // 굳혀 버리면 이후 제거가 시드를 품은 파일을 복원한다. + $this->runSeedCommand(); + $this->runSeedCommand(); + + $this->assertSame($original, File::get($this->routesBackupPath()), '백업은 최초 원본을 유지해야 한다'); + } + + /** + * @effects seed_layout_warns_on_template_without_declared_seed_route + */ + public function test_시드_라우트가_정의되지_않은_템플릿은_경고하고_건너뛴다(): void + { + $this->setBypassFlag(); + + try { + // 대상이 전부 걸러지면 처리할 것이 없다 — 조용한 성공보다 명시적 실패가 낫다. + $this->artisan('playwright:seed-layout', ['--template' => ['no-such-template']]) + ->expectsOutputToContain('no-such-template') + ->assertExitCode(1); + } finally { + $this->clearBypassFlag(); + } + } + + /** + * 커맨드를 옵트인 환경변수와 함께 실행합니다. + * + * @param array $options 추가 커맨드 옵션 + * @param int $exitCode 기대 종료 코드 + */ + private function runSeedCommand(array $options = [], int $exitCode = 0): void + { + $this->setBypassFlag(); + + try { + $this->artisan('playwright:seed-layout', $options)->assertExitCode($exitCode); + } finally { + $this->clearBypassFlag(); + } + } + + /** + * 옵트인 플래그를 설정합니다. + * + * Laravel `env()` 는 `$_SERVER` → `$_ENV` → `putenv` 순으로 읽으므로 세 곳을 모두 채운다. + * `putenv` 만 쓰면 앞선 어댑터가 값을 먼저 결정하는 환경에서 조용히 무시된다. + */ + private function setBypassFlag(): void + { + $_SERVER['G7_PLAYWRIGHT_BYPASS'] = '1'; + $_ENV['G7_PLAYWRIGHT_BYPASS'] = '1'; + putenv('G7_PLAYWRIGHT_BYPASS=1'); + } + + /** 옵트인 플래그를 제거합니다 (다른 테스트로 새지 않도록). */ + private function clearBypassFlag(): void + { + unset($_SERVER['G7_PLAYWRIGHT_BYPASS'], $_ENV['G7_PLAYWRIGHT_BYPASS']); + putenv('G7_PLAYWRIGHT_BYPASS'); + } + + /** + * 대상 템플릿의 DB 행을 만듭니다. + * + * @return Template 생성된 템플릿 모델 + */ + private function makeTemplate(): Template + { + return Template::factory()->create([ + 'identifier' => self::TEMPLATE, + 'type' => 'user', + 'status' => 'active', + ]); + } + + /** + * 활성 템플릿 디렉토리가 없으면 테스트를 건너뜁니다. + * + * 활성 디렉토리는 Git 무시 대상이라 클린 체크아웃에는 존재하지 않는다. 그 환경에서는 + * 커맨드가 경고 후 건너뛰므로 본 테스트가 검증할 대상이 없다. + */ + private function skipIfTemplateDirMissing(): void + { + if (! File::isDirectory(base_path('templates/'.self::TEMPLATE))) { + $this->markTestSkipped('활성 템플릿 디렉토리('.self::TEMPLATE.')가 없어 건너뜁니다.'); + } + } + + /** 활성 템플릿의 routes.json 절대 경로 */ + private function routesPath(): string + { + return base_path('templates/'.self::TEMPLATE.'/routes.json'); + } + + /** 원본 routes.json 보관 파일 절대 경로 (커맨드가 설치 시 생성) */ + private function routesBackupPath(): string + { + return $this->routesPath().'.playwright-backup'; + } + + /** 시드 레이아웃 파일 절대 경로 */ + private function layoutPath(): string + { + return base_path('templates/'.self::TEMPLATE.'/layouts/'.self::LAYOUT_NAME.'.json'); + } + + /** + * 활성 routes.json 의 전체 라우트 배열을 반환합니다. + * + * @return array> + */ + private function allRoutes(): array + { + $decoded = json_decode((string) File::get($this->routesPath()), true); + + return $decoded['routes'] ?? []; + } + + /** + * 활성 routes.json 에서 시드 마커가 붙은 라우트만 반환합니다. + * + * @return array> + */ + private function seedRoutes(): array + { + return array_values(array_filter( + $this->allRoutes(), + fn ($route) => is_array($route) && ($route['_marker'] ?? null) === 'playwright-seed-layout' + )); + } +} diff --git a/tests/Playwright/fixtures/layout-editor.ts b/tests/Playwright/fixtures/layout-editor.ts new file mode 100644 index 00000000..6af182ab --- /dev/null +++ b/tests/Playwright/fixtures/layout-editor.ts @@ -0,0 +1,68 @@ +/** + * 레이아웃 편집기 spec 공용 헬퍼. + * + * 편집기 캔버스의 노드는 `data-editor-path` 로 지목하는데, 그 경로의 **첫 세그먼트는 베이스 + * 레이아웃(`_user_base` / `_admin_base`)의 루트 자식 인덱스**다. 베이스 레이아웃 루트에 컴포넌트가 + * 하나 추가되면 본문 루트 인덱스가 통째로 밀려 그 인덱스를 리터럴로 박아 둔 spec 이 전부 + * `node not found` 로 죽는다. + * + * 실제로 그렇게 됐다 — `_user_base` 루트가 작성 시점에는 + * `0:Toast / 1:PageTransitionIndicator / 2:user_layout_root` 였는데 + * `header_currency_inject_anchor` Div 가 0번에 삽입되어 본문이 2 → 3 으로 밀렸고, `'2.children…'` + * 을 상수로 둔 spec 7개(31건)가 한꺼번에 실패했다. + * + * 그래서 인덱스를 세지 않고 **본문 루트의 DOM id 로 경로를 조회**한다. 베이스 레이아웃에 무엇이 + * 더 얹혀도 영향받지 않는다. + */ +import type { Page } from '@playwright/test'; + +/** 사용자 템플릿 본문 루트의 DOM id (`_user_base.json` 의 `user_layout_root`) */ +export const USER_BODY_ROOT_ID = 'user_layout_root'; + +/** 관리자 템플릿 본문 루트의 DOM id (`_admin_base.json`) */ +export const ADMIN_BODY_ROOT_ID = 'admin_layout_root'; + +/** + * 편집기 캔버스에서 본문 루트의 `data-editor-path` 를 조회합니다. + * + * @param page Playwright page (편집기 진입 + 캔버스 렌더 완료 상태) + * @param rootId 본문 루트의 DOM id (기본값: 사용자 템플릿) + * @returns 본문 루트의 editor path (예: `"3"`) + * @throws 해당 id 노드가 없거나 editor path 가 없을 때 + */ +export async function bodyRootPath(page: Page, rootId: string = USER_BODY_ROOT_ID): Promise { + const path = await page.evaluate( + (id) => document.querySelector(`#${id}`)?.getAttribute('data-editor-path') ?? null, + rootId, + ); + + if (!path) { + const seen = await page.evaluate(() => + Array.from(document.querySelectorAll('[data-editor-path]')) + .filter((el) => !(el.getAttribute('data-editor-path') ?? '').includes('.')) + .map((el) => `${el.getAttribute('data-editor-path')}#${el.id || '(no-id)'}`) + .join(', '), + ); + throw new Error(`본문 루트(#${rootId})의 data-editor-path 를 찾지 못했습니다. 루트 노드: ${seen}`); + } + + return path; +} + +/** + * 본문 루트 기준 상대 경로를 절대 editor path 로 만듭니다. + * + * @param page Playwright page + * @param relative 본문 루트 아래 상대 경로 (예: `"children.5.children.0"`) + * @param rootId 본문 루트의 DOM id (기본값: 사용자 템플릿) + * @returns 절대 editor path + */ +export async function editorPath( + page: Page, + relative: string, + rootId: string = USER_BODY_ROOT_ID, +): Promise { + const root = await bodyRootPath(page, rootId); + + return relative ? `${root}.${relative}` : root; +} diff --git a/tests/Playwright/fixtures/seed-layout.ts b/tests/Playwright/fixtures/seed-layout.ts new file mode 100644 index 00000000..fdc485a4 --- /dev/null +++ b/tests/Playwright/fixtures/seed-layout.ts @@ -0,0 +1,91 @@ +/** + * 레이아웃 편집기 E2E 전용 시드 화면 — 상수 + provisioning 헬퍼. + * + * 편집기 spec 중 **저장(PUT)까지 수행하는** 것들은 편집 결과가 그대로 영속된다. 그 대상이 + * 제품 화면(`home` / `admin_dashboard`)이면 실행할 때마다 빈 표·차트가 누적돼 개발 사이트가 + * 오염된다(실측: `home` 20,321 → 33,696 bytes, 빈 표 7개). spec 안에 "추가한 노드 삭제 후 + * 재저장" 원복을 넣어도 편집기가 캐시한 문서를 다시 밀어 넣어 오염 시점 크기로 되돌아왔다. + * + * 그래서 저장 spec 은 제품 화면 대신 전용 시드 화면(`e2e_sandbox`)을 대상으로 한다. 설치/제거는 + * `php artisan playwright:seed-layout` 에 위임하며, globalSetup/globalTeardown 이 호출한다. + * + * 시드 화면은 **활성 템플릿 디렉토리에만** 설치된다 — `_bundled`(배포 원본)은 변경되지 않고, + * 활성 디렉토리는 Git 무시 대상이라 릴리스 산출물에도 포함되지 않는다. + * + * 저장하지 않는(읽기 전용) spec 은 계속 제품 화면을 대상으로 둔다 — 오염 위험이 없고, + * 실제 제품 레이아웃에 대한 검증이 유지되는 편이 낫다. + */ +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// ESM 환경(package.json "type": "module")에서는 __dirname 이 정의되지 않으므로 +// import.meta.url 로 재구성한다. +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * 시드 화면의 라우트 경로 (URL 인코딩 전 원문). + * + * `PlaywrightSeedLayout::DEFAULT_TARGETS` 와 동일해야 한다 — 한쪽만 바꾸면 시드는 설치되는데 + * spec 이 그 경로를 못 찾아 라우트 트리 매칭이 실패한다. + */ +export const SANDBOX_ROUTE = { + 'sirsoft-basic': '/e2e-sandbox', + 'sirsoft-admin_basic': '*/admin/e2e-sandbox', +} as const; + +/** + * 시드 화면 본문 컨테이너의 노드 id — `editorPath(page, rel, SANDBOX_ROOT_ID)` 의 기준점. + * + * 제품 화면에서는 어느 Div 가 선택 가능한지 정해져 있지 않아 spec 이 후보를 순회하거나 + * `children.5.children.0...` 같은 인덱스 체인을 써야 했다. 시드 화면은 이 id 를 고정하므로 + * 컨테이너를 곧바로 지목할 수 있다. + */ +export const SANDBOX_ROOT_ID = 'e2e_sandbox_root'; + +/** + * 편집기 진입 URL 의 `?route=` 에 넣을 인코딩된 시드 라우트를 반환한다. + * + * spec 의 `gotoEditor(page, route)` 가 `?route=${route}` 로 조립하므로 인코딩된 값을 넘긴다. + * + * @param template 편집 대상 템플릿 identifier (기본값: 사용자 템플릿) + * @returns URL 인코딩된 시드 라우트 경로 + */ +export function sandboxRouteParam( + template: keyof typeof SANDBOX_ROUTE = 'sirsoft-basic', +): string { + return encodeURIComponent(SANDBOX_ROUTE[template]); +} + +/** + * `php artisan` 을 실행할 코어 루트를 결정한다. + * + * 확장(모듈/플러그인/템플릿) 은 자기 디렉토리의 config 로 Playwright 를 띄우므로 `process.cwd()` + * 가 확장 디렉토리다 — 그 위치에는 artisan 이 없다. 본 파일은 항상 + * `<코어루트>/tests/Playwright/fixtures/` 에 있으므로 자기 위치에서 코어 루트를 역산한다. + */ +function resolveCoreRoot(): string { + return process.env.G7_ROOT || resolve(__dirname, '../../../'); +} + +/** + * `playwright:seed-layout` 커맨드를 실행한다. + * + * @param remove true 면 시드 화면 제거, false 면 설치 + */ +export function runSeedLayoutCommand(remove: boolean): void { + const args = ['artisan', 'playwright:seed-layout']; + if (remove) args.push('--remove'); + + execFileSync('php', args, { + cwd: resolveCoreRoot(), + encoding: 'utf-8', + stdio: 'inherit', + env: { + ...process.env, + // PlaywrightSeedLayout 의 옵트인 가드를 통과시킨다. + // .env 영구 수정 없이 자식 프로세스에만 부여 — 운영 환경 격리 보존. + G7_PLAYWRIGHT_BYPASS: '1', + }, + }); +} diff --git a/tests/Playwright/fixtures/seed-layouts/sirsoft-admin_basic.e2e_sandbox.json b/tests/Playwright/fixtures/seed-layouts/sirsoft-admin_basic.e2e_sandbox.json new file mode 100644 index 00000000..12bfff9c --- /dev/null +++ b/tests/Playwright/fixtures/seed-layouts/sirsoft-admin_basic.e2e_sandbox.json @@ -0,0 +1,58 @@ +{ + "version": "1.0.0", + "layout_name": "e2e_sandbox", + "extends": "_admin_base", + "meta": { + "title": "E2E Sandbox (admin)", + "description": "레이아웃 편집기 E2E 전용 시드 화면. 제품 화면이 아니며 playwright:seed-layout 커맨드가 활성 디렉토리에만 설치한다.", + "auth_required": true + }, + "slots": { + "content": [ + { + "id": "e2e_sandbox_root", + "type": "basic", + "name": "Div", + "props": { + "className": "p-4 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800" + }, + "children": [ + { + "type": "basic", + "name": "H2", + "props": { + "className": "text-lg font-bold text-gray-900 dark:text-gray-100" + }, + "text": "E2E 샌드박스" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "mt-1 text-sm text-gray-500 dark:text-gray-400" + }, + "text": "레이아웃 편집기 E2E 가 컴포넌트를 넣고 저장하는 전용 화면입니다." + }, + { + "id": "e2e_sandbox_slot", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-4 min-h-24 p-3 rounded border border-dashed border-gray-300 dark:border-gray-600" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + }, + "text": "sandbox slot" + } + ] + } + ] + } + ] + } +} diff --git a/tests/Playwright/fixtures/seed-layouts/sirsoft-basic.e2e_sandbox.json b/tests/Playwright/fixtures/seed-layouts/sirsoft-basic.e2e_sandbox.json new file mode 100644 index 00000000..5ebbb7c5 --- /dev/null +++ b/tests/Playwright/fixtures/seed-layouts/sirsoft-basic.e2e_sandbox.json @@ -0,0 +1,69 @@ +{ + "version": "1.0.0", + "layout_name": "e2e_sandbox", + "extends": "_user_base", + "meta": { + "title": "E2E Sandbox (user)", + "description": "레이아웃 편집기 E2E 전용 시드 화면. 제품 화면이 아니며 playwright:seed-layout 커맨드가 활성 디렉토리에만 설치한다.", + "seo": { + "enabled": false + } + }, + "slots": { + "content": [ + { + "type": "layout", + "name": "Container", + "props": { + "className": "py-6 px-4" + }, + "children": [ + { + "id": "e2e_sandbox_root", + "type": "basic", + "name": "Div", + "props": { + "className": "p-4 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800" + }, + "children": [ + { + "type": "basic", + "name": "H2", + "props": { + "className": "text-lg font-bold text-gray-900 dark:text-gray-100" + }, + "text": "E2E 샌드박스" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "mt-1 text-sm text-gray-500 dark:text-gray-400" + }, + "text": "레이아웃 편집기 E2E 가 컴포넌트를 넣고 저장하는 전용 화면입니다." + }, + { + "id": "e2e_sandbox_slot", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-4 min-h-24 p-3 rounded border border-dashed border-gray-300 dark:border-gray-600" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + }, + "text": "sandbox slot" + } + ] + } + ] + } + ] + } + ] + } +} diff --git a/tests/Playwright/global-setup.ts b/tests/Playwright/global-setup.ts new file mode 100644 index 00000000..4e3467e9 --- /dev/null +++ b/tests/Playwright/global-setup.ts @@ -0,0 +1,13 @@ +/** + * Playwright globalSetup (코어 E2E). + * + * 레이아웃 편집기 저장 spec 전용 시드 화면(`e2e_sandbox`)을 설치한다. 매 실행마다 fixture + * 원본으로 덮어쓰므로, 직전 실행이 남긴 편집 결과가 다음 실행에 새지 않는다(테스트 격리). + * + * 배경과 설치 범위는 `fixtures/seed-layout.ts` 참조. + */ +import { runSeedLayoutCommand } from './fixtures/seed-layout'; + +export default function globalSetup(): void { + runSeedLayoutCommand(false); +} diff --git a/tests/Playwright/global-teardown.ts b/tests/Playwright/global-teardown.ts new file mode 100644 index 00000000..4b29ab58 --- /dev/null +++ b/tests/Playwright/global-teardown.ts @@ -0,0 +1,14 @@ +/** + * Playwright globalTeardown (코어 E2E). + * + * 레이아웃 편집기 저장 spec 전용 시드 화면(`e2e_sandbox`)을 제거해 개발 사이트에 흔적을 + * 남기지 않는다. 실행이 비정상 종료해 시드가 남더라도 다음 globalSetup 이 원본으로 덮어쓰므로 + * 테스트 격리는 유지된다. + * + * 배경과 제거 범위는 `fixtures/seed-layout.ts` 참조. + */ +import { runSeedLayoutCommand } from './fixtures/seed-layout'; + +export default function globalTeardown(): void { + runSeedLayoutCommand(true); +} diff --git a/tests/Playwright/specs/admin/settings-info-opcache.spec.ts b/tests/Playwright/specs/admin/settings-info-opcache.spec.ts index b85ca097..5e0805e5 100644 --- a/tests/Playwright/specs/admin/settings-info-opcache.spec.ts +++ b/tests/Playwright/specs/admin/settings-info-opcache.spec.ts @@ -35,14 +35,22 @@ async function stubOpcache( opcache: { loaded: boolean; enabled: boolean | null }, ): Promise { await page.route(SYSTEM_INFO_API, async (route) => { - const response = await route.fetch(); - const body = await response.json(); + try { + const response = await route.fetch(); + const body = await response.json(); - if (body?.data) { - body.data.opcache = opcache; + if (body?.data) { + body.data.opcache = opcache; + } + + await route.fulfill({ response, json: body }); + } catch { + // 테스트가 끝난 뒤 도착한 요청에서 `route.fetch()` 는 "Test ended" 로 거부된다. + // 이 콜백은 테스트가 await 하지 않으므로 그 거부가 **어느 테스트에도 속하지 않는 에러**가 + // 되고, 워커가 중단되어 남은 테스트가 "did not run" 으로 집계된다(실측: 전수 실행에서 + // 에러 2건 → 미실행 2건). 종료 직후 요청은 조용히 통과시킨다. + await route.continue().catch(() => undefined); } - - await route.fulfill({ response, json: body }); }); } diff --git a/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts b/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts index fee9300a..7f6dec26 100644 --- a/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts +++ b/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts @@ -16,6 +16,13 @@ */ import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth'; +// 이 파일의 테스트 다수가 **같은 관리자 설정 화면에 실제로 저장**한다(SEO 탭 sitemap 설정). +// 전역 `fullyParallel: true` 아래서는 같은 파일 안 테스트도 워커에 흩어져 동시 실행되고, 서로의 +// 저장 상태를 덮어써 간헐 실패한다(실측: admin 디렉토리 병렬 실행에서 "분할 기준 저장" 30초 타임아웃). +// `mode: 'default'` 는 이 파일만 단일 워커 순차 실행으로 되돌린다 — `serial` 과 달리 앞 테스트가 +// 실패해도 뒤 테스트를 건너뛰지 않는다. +test.describe.configure({ mode: 'default' }); + const URLS_PER_FILE = '#field_sitemap_urls_per_file'; const GZIP_ROW = '#field_sitemap_gzip'; const HREFLANG_ROW = '#field_sitemap_hreflang_enabled'; @@ -213,7 +220,17 @@ test('#481 - "지금 생성" 클릭이 큐에 예약되고 진행상황이 화 ); const startedAt = Date.now(); - await page.locator('#sitemap_last_updated_block button').first().click(); + // 재생성 버튼은 진행 중(queued/running/writing)이면 disabled 다. + // 재생성은 큐로 위임되므로 **큐 워커가 돌지 않는 환경**에서는 상태가 `queued` 로 멈춰 + // 버튼이 영원히 disabled 다(진행상황 캐시 TTL 1시간). 코드 결함이 아니라 실행 환경 + // 전제이므로, 그런 환경에서는 이 테스트를 건너뛴다. + const regenerateButton = page.locator('#sitemap_last_updated_block button').first(); + const clickable = await regenerateButton + .waitFor({ state: 'attached', timeout: 10_000 }) + .then(() => regenerateButton.isEnabled()) + .catch(() => false); + test.skip(!clickable, '이전 sitemap 재생성이 queued 로 남아 있다 — 큐 워커 미가동 환경'); + await regenerateButton.click(); const response = await regenerate; const elapsed = Date.now() - startedAt; @@ -278,7 +295,17 @@ test('#481 - 재생성 후 실시간/폴링 모드에 맞게 진행상황이 갱 // 여기부터 재생성 이후의 폴링만 센다 statusGets = 0; - await page.locator('#sitemap_last_updated_block button').first().click(); + // 재생성 버튼은 진행 중(queued/running/writing)이면 disabled 다. + // 재생성은 큐로 위임되므로 **큐 워커가 돌지 않는 환경**에서는 상태가 `queued` 로 멈춰 + // 버튼이 영원히 disabled 다(진행상황 캐시 TTL 1시간). 코드 결함이 아니라 실행 환경 + // 전제이므로, 그런 환경에서는 이 테스트를 건너뛴다. + const regenerateButton = page.locator('#sitemap_last_updated_block button').first(); + const clickable = await regenerateButton + .waitFor({ state: 'attached', timeout: 10_000 }) + .then(() => regenerateButton.isEnabled()) + .catch(() => false); + test.skip(!clickable, '이전 sitemap 재생성이 queued 로 남아 있다 — 큐 워커 미가동 환경'); + await regenerateButton.click(); await page.waitForResponse((r) => r.url().includes('/api/admin/seo/sitemap/regenerate'), { timeout: 20_000 }); const pollingMode = realtimeEnabled === false; @@ -298,32 +325,116 @@ test('#481 - 재생성 후 실시간/폴링 모드에 맞게 진행상황이 갱 // @scenario tab=seo, transition_overlay=wait_for // @effects sitemap_status_awaited_before_reveal +// +// 측정 방식 정정 (2026-07-30 실측): +// +// 이 테스트는 원래 "`#sitemap_last_updated_block` 이 보이는 시점 = 오버레이가 걷힌 시점" 을 +// 전제로 블록 가시성 시각을 쟀다. 그 전제가 틀렸다 — 전환 오버레이는 타겟 **안에** +// `#g7-skeleton-overlay` 를 덧붙이는 방식이고 기존 콘텐츠는 DOM 에 그대로 남는데, +// Playwright 의 `toBeVisible()` 은 가림(occlusion)을 보지 않으므로 덮여 있어도 visible 로 판정한다. +// +// 오버레이 **컨테이너**(`#g7-skeleton-overlay`)를 관측 대상으로 삼는 것도 불안정했다. 그것은 +// 타겟 안에 있어 렌더가 타겟을 갈아치울 때마다 사라졌다 재부착된다 — 하드 진입은 렌더 **전** +// 단계라 타겟도 폴백도 아직 없어 `#app` 로 3단계 폴백되는데, 곧이어 첫 렌더가 `#app` 을 갈아치워 +// 40ms 표본에 한 번도 잡히지 않는다(실측: appendChild 후킹으로 `#app` 2회 부착만 확인됨). +// 대조군으로 `/admin/users` 하드 진입도 동일함을 확인했다 — SEO 탭 고유 문제가 아니라 하드 진입 +// 일반의 동작이다. +// +// 그래서 두 가지를 바꿨다. +// ① 진입 경로를 **SPA 전환**으로 (실제 사용자 흐름도 탭 전환이라 가드 의도에도 맞다) +// ② 관측 대상을 `` 의 `#g7-skeleton-overlay-style` 로. 이 스타일 엘리먼트는 렌더 영향을 +// 받지 않고, 오버레이 해제(`hideTransitionOverlay`)가 이것을 명시적으로 제거한다 — +// "오버레이가 살아 있는가" 의 안정적인 신호다. test('#481 - SEO 탭 콘텐츠 노출 시점에 sitemap 상태가 이미 채워져 있다', async ({ page }) => { const token = issueToken('core.settings.read', 'core.settings.update'); await authenticatePage(page, token); - // 상태 API 를 의도적으로 지연시켜, 전환 오버레이가 이 데이터소스를 기다리는지 드러낸다. - // wait_for 에 sitemap_status 가 빠져 있으면 오버레이가 먼저 걷히고 빈 상태로 노출된다. - let statusResolvedAt = 0; - await page.route('**/api/admin/seo/sitemap/status*', async (route) => { - await new Promise((r) => setTimeout(r, 1_500)); - statusResolvedAt = Date.now(); - await route.continue(); + // 오버레이 컨테이너의 존재 여부를 페이지 안에서 촘촘히 표본한다. + // + // 제거 시각을 `Element.remove` 후킹으로 잡으면 놓친다 — 첫 렌더가 `#app` 을 통째로 교체할 때는 + // 그 API 를 거치지 않고 사라진다. 부착은 appendChild 로 확실히 잡고, 사라짐은 "마지막으로 + // 보였던 시각(lastSeenAt)" 으로 판정한다. 부팅 중 잠깐 사라졌다 재부착되는 구간이 있으므로 + // (렌더가 지운 뒤 reattachSpinnerOverlay 가 다시 붙임) 단발 부재가 아니라 lastSeenAt 을 쓴다. + await page.addInitScript(() => { + (window as any).__overlay = { mountedAt: 0, lastSeenAt: 0 }; + // 오버레이 컨테이너(`#g7-skeleton-overlay`)는 타겟 안에 있어 렌더가 타겟을 갈아치우면 함께 + // 사라졌다가 재부착된다 — 표본으로 잡으면 있다가 없다가 한다. 반면 스타일 엘리먼트는 + // `` 에 있어 렌더 영향을 받지 않고, 오버레이 해제(`hideTransitionOverlay`)가 이것을 + // 명시적으로 제거한다. 그래서 "오버레이가 살아 있는가" 의 안정적인 신호는 이쪽이다. + const STYLE_ID = 'g7-skeleton-overlay-style'; + const origAppend = Node.prototype.appendChild; + Node.prototype.appendChild = function (this: Node, node: T): T { + if ((node as unknown as Element)?.id === STYLE_ID) { + (window as any).__overlay.mountedAt ||= Date.now(); + } + + return origAppend.call(this, node) as T; + }; + setInterval(() => { + if (document.getElementById(STYLE_ID)) { + (window as any).__overlay.lastSeenAt = Date.now(); + } + }, 50); }); - await page.goto('/admin/settings?tab=seo'); + // 상태 API 를 의도적으로 지연시켜, 전환 오버레이가 이 데이터소스를 기다리는지 드러낸다. + // wait_for 에 sitemap_status 가 빠지면 오버레이가 먼저 걷히고 빈 상태로 노출된다. + let statusResolvedAt = 0; + await page.route( + (url) => url.pathname.endsWith('/api/admin/seo/sitemap/status'), + async (route) => { + await new Promise((r) => setTimeout(r, 1_500)); + statusResolvedAt = Date.now(); + await route.continue().catch(() => undefined); + }, + ); + + // 다른 탭으로 먼저 들어간 뒤 SPA 전환으로 SEO 탭에 진입한다 (위 주석 참조). + await page.goto('/admin/settings?tab=general'); await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await page.waitForFunction(() => !!(window as any).G7Core?.dispatch, { timeout: 30_000 }); + await page.waitForTimeout(3_000); + // 진입 전 상태를 초기화해 SEO 탭 전환 구간만 측정한다. + await page.evaluate(() => ((window as any).__overlay = { mountedAt: 0, lastSeenAt: 0 })); - // 진행상황 블록이 보이는 시점 = 오버레이가 걷힌 시점 - const statusBlock = page.locator('#sitemap_last_updated_block'); - await expect(statusBlock).toBeVisible({ timeout: 30_000 }); - const revealedAt = Date.now(); + await page.evaluate(() => { + (window as any).G7Core.dispatch({ + handler: 'navigate', + params: { path: '/admin/settings', query: { tab: 'seo' } }, + }); + }); - // 상대 비교: 노출 시점이 상태 응답 이후여야 한다 (절대 임계값 사용 안 함) - expect(statusResolvedAt).toBeGreaterThan(0); - expect(revealedAt).toBeGreaterThanOrEqual(statusResolvedAt); + // 오버레이가 실제로 걸렸는지 먼저 확인한다 — 걸리지 않았다면 이 가드는 아무것도 측정하지 못한다. + await expect + .poll(() => page.evaluate(() => (window as any).__overlay?.mountedAt ?? 0), { + message: '전환 오버레이가 한 번도 마운트되지 않았다 — wait_for 가드가 동작하지 않는 상태', + timeout: 20_000, + }) + .toBeGreaterThan(0); + + // 지연시킨 상태 응답이 실제로 도착할 때까지 기다린다. + await expect + .poll(() => statusResolvedAt, { message: 'sitemap 상태 응답이 오지 않았다', timeout: 20_000 }) + .toBeGreaterThan(0); + + // 판정: **상태 응답이 도착한 그 시점에 오버레이가 아직 떠 있었는가**. + // + // "오버레이가 완전히 걷히는 시각" 으로 재지 않는다 — 사이트맵 생성이 `queued` 로 남아 있는 + // 환경(큐 워커 미가동)에서는 `sitemap_status.onSuccess` 가 3초 폴링을 걸고, 그 refetch 가 + // `wait_for` 대상이라 오버레이를 매번 다시 띄운다. 그러면 오버레이가 영영 걷히지 않아 측정 + // 자체가 불가능하다(실측: 40초 내내 유지). 이 순환은 환경 조건이지 가드 대상이 아니다. + // + // `wait_for` 에서 sitemap_status 가 빠지면 오버레이가 1.5초 지연 응답보다 먼저 걷히므로 + // `lastSeenAt < statusResolvedAt` 이 되어 실패한다 — 가드 의도는 그대로 유지된다. + const lastSeenAt = await page.evaluate(() => (window as any).__overlay.lastSeenAt as number); + expect( + lastSeenAt, + '상태 응답이 도착하기 전에 전환 오버레이가 걷혔다 — wait_for 가 이 데이터소스를 기다리지 않는다', + ).toBeGreaterThanOrEqual(statusResolvedAt - 100); // 노출된 내용이 빈 껍데기가 아니어야 한다 (미해석 바인딩/빈 문자열 회귀 가드) + const statusBlock = page.locator('#sitemap_last_updated_block'); + await expect(statusBlock).toBeVisible({ timeout: 15_000 }); const text = (await statusBlock.innerText()).trim(); expect(text).not.toContain('$t:'); expect(text.length).toBeGreaterThan(0); diff --git a/tests/Playwright/specs/layout-editor/action-flex-condition.spec.ts b/tests/Playwright/specs/layout-editor/action-flex-condition.spec.ts index a8724bb4..1f69ba0f 100644 --- a/tests/Playwright/specs/layout-editor/action-flex-condition.spec.ts +++ b/tests/Playwright/specs/layout-editor/action-flex-condition.spec.ts @@ -171,19 +171,26 @@ test.describe('@layout-editor 동작/표시조건/정렬박스 (S7)', () => { // 첫 이벤트 슬롯에 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="g7le-action-add-0-toggle"]').click(); + 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^="g7le-action-edit-0-"]'); + const editBtns = firstSlot.locator(`[data-testid^="${SLOT}-edit-"]`); await editBtns.last().click(); - const onSuccess = page.getByTestId('g7le-action-param-onSuccess'); + const onSuccess = page.getByTestId(`${SLOT}-edit-param-onSuccess`); await expect(onSuccess).toBeVisible(); // 빈 상태("사용할 수 있는 동작이 없습니다") 가 아니라 중첩 add 토글이 있어야 한다(rawRecipes 전달). - await expect(onSuccess.getByTestId('g7le-action-add-1-empty')).toHaveCount(0); - await expect(onSuccess.getByTestId('g7le-action-add-1-toggle')).toHaveCount(1); + await expect(onSuccess.getByTestId(`${NESTED}-add-picker-empty`)).toHaveCount(0); + await expect(onSuccess.getByTestId(`${NESTED}-add-picker-toggle`)).toHaveCount(1); // 펼치면 코어 핸들러(안내 메시지 등) 노출. - await onSuccess.getByTestId('g7le-action-add-1-toggle').click(); + await onSuccess.getByTestId(`${NESTED}-add-picker-toggle`).click(); await expect(onSuccess.getByTestId('g7le-init-action-spec-toast')).toHaveCount(1); }); @@ -194,11 +201,15 @@ test.describe('@layout-editor 동작/표시조건/정렬박스 (S7)', () => { expect(await openActionTab(page)).toBe(true); // 동작 1개 추가(toast) → 카드 생성 → 드래그 핸들 draggable 확인. - const slotAddToggle = page.locator('[data-testid="g7le-action-editor"] [data-testid="g7le-action-add-0-toggle"]').first(); + // 이벤트 슬롯 빌더 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.getByTestId('g7le-action-drag-0-0').first(); + const handle = page.locator('[data-testid="g7le-action-editor"] [data-testid*="-drag-"]').first(); await expect(handle).toBeVisible(); await expect(handle).toHaveAttribute('draggable', 'true'); }); diff --git a/tests/Playwright/specs/layout-editor/action-reorder-drag.spec.ts b/tests/Playwright/specs/layout-editor/action-reorder-drag.spec.ts index 5fd262c7..7e7a693d 100644 --- a/tests/Playwright/specs/layout-editor/action-reorder-drag.spec.ts +++ b/tests/Playwright/specs/layout-editor/action-reorder-drag.spec.ts @@ -30,21 +30,33 @@ async function openInitActionsTab(page: PwPage): Promise { await page.waitForSelector('[data-testid="g7le-init-actions-form"]', { timeout: 10_000 }); } -/** 화면 동작 2개(setState, toast)를 추가한다. */ -async function addTwoActions(page: PwPage): Promise { - await page.click('[data-testid="g7le-action-add-toggle"]'); +/** + * 화면 동작 2개(setState, toast)를 추가하고 **추가 전 항목 수**를 돌려준다. + * + * 대상 레이아웃에는 이미 화면 동작이 있으므로 "항목은 2개" 로 고정하지 않는다 — 카드 인덱스는 + * 전체 목록 기준이라 기존 항목 뒤에 쌓인다. + * + * @param page Playwright page + * @returns 추가 전 항목 수 (= 첫 번째로 추가된 항목의 인덱스) + */ +async function addTwoActions(page: PwPage): Promise { + const base = (await cardTitles(page)).length; + + await page.click('[data-testid="g7le-init-action-self-add-picker-toggle"]'); await page.click('[data-testid="g7le-init-action-spec-setState"]'); - await page.click('[data-testid="g7le-action-add-toggle"]'); + await page.click('[data-testid="g7le-init-action-self-add-picker-toggle"]'); await page.click('[data-testid="g7le-init-action-spec-toast"]'); - await expect(page.locator('[data-testid="g7le-init-action-item-1"]')).toBeAttached(); + await expect(page.locator(`[data-testid="g7le-init-action-self-item-${base + 1}"]`)).toBeAttached(); + + return base; } /** 카드(fromIdx)를 카드(overIdx) 아래 절반으로 HTML5 드래그&드롭한다. */ async function dragActionCard(page: PwPage, fromIdx: number, overIdx: number): Promise { await page.evaluate( async ({ from, over }) => { - const handle = document.querySelector(`[data-testid="g7le-init-action-reorder-${from}"]`)!; - const card = document.querySelector(`[data-testid="g7le-init-action-item-${over}"]`)! as HTMLElement; + const handle = document.querySelector(`[data-testid="g7le-init-action-self-drag-${from}"]`)!; + const card = document.querySelector(`[data-testid="g7le-init-action-self-item-${over}"]`)! as HTMLElement; const dt = new DataTransfer(); const r = card.getBoundingClientRect(); handle.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: dt })); @@ -63,7 +75,7 @@ async function dragActionCard(page: PwPage, fromIdx: number, overIdx: number): P /** 동작 카드 타이틀 순서를 읽는다. */ async function cardTitles(page: PwPage): Promise { return page.evaluate(() => - Array.from(document.querySelectorAll('[data-testid^="g7le-init-action-item-"]')).map( + Array.from(document.querySelectorAll('[data-testid^="g7le-init-action-self-item-"]')).map( (e) => (e.querySelector('[style*="font-weight"]')?.textContent ?? '').trim(), ), ); @@ -77,28 +89,28 @@ test.describe('@layout-editor 화면 동작 순서 변경 드래그 (S10-1 후 await addTwoActions(page); // ▲▼ 순서 변경 버튼은 제거됐다. - await expect(page.locator('[data-testid="g7le-init-action-up-0"]')).toHaveCount(0); - await expect(page.locator('[data-testid="g7le-init-action-down-0"]')).toHaveCount(0); + await expect(page.locator('[data-testid="g7le-init-action-self-up-0"]')).toHaveCount(0); + await expect(page.locator('[data-testid="g7le-init-action-self-down-0"]')).toHaveCount(0); // ⠿ 드래그 핸들은 draggable 로 존재한다. - await expect(page.locator('[data-testid="g7le-init-action-reorder-0"]')).toHaveAttribute('draggable', 'true'); - await expect(page.locator('[data-testid="g7le-init-action-reorder-1"]')).toHaveAttribute('draggable', 'true'); + await expect(page.locator('[data-testid="g7le-init-action-self-drag-0"]')).toHaveAttribute('draggable', 'true'); + await expect(page.locator('[data-testid="g7le-init-action-self-drag-1"]')).toHaveAttribute('draggable', 'true'); // 삽입선은 비드래그 시 비활성. - await expect(page.locator('[data-testid="g7le-init-action-dropline-end"]')).toHaveAttribute('data-active', 'false'); + await expect(page.locator('[data-testid="g7le-init-action-self-dropline-end"]')).toHaveAttribute('data-active', 'false'); }); test('⠿ 드래그로 순서 변경 + 드롭 예정 지점 삽입선 활성', async ({ page }) => { const token = issueToken('core.templates.layouts.edit'); await authenticatePage(page, token); await openInitActionsTab(page); - await addTwoActions(page); + const base = await addTwoActions(page); const before = await cardTitles(page); - expect(before.length).toBe(2); + expect(before.length).toBe(base + 2); // 드래그 중 끝 삽입선이 활성화되는지 — dragstart → over 사이를 별도로 측정. - const activeDuringOver = await page.evaluate(async () => { - const handle = document.querySelector('[data-testid="g7le-init-action-reorder-0"]')!; - const card = document.querySelector('[data-testid="g7le-init-action-item-1"]')! as HTMLElement; + const activeDuringOver = await page.evaluate(async (b) => { + const handle = document.querySelector(`[data-testid="g7le-init-action-self-drag-${b}"]`)!; + const card = document.querySelector(`[data-testid="g7le-init-action-self-item-${b + 1}"]`)! as HTMLElement; const dt = new DataTransfer(); const r = card.getBoundingClientRect(); handle.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: dt })); @@ -107,36 +119,38 @@ test.describe('@layout-editor 화면 동작 순서 변경 드래그 (S10-1 후 new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt, clientY: r.top + r.height * 0.75 }), ); await new Promise((res) => setTimeout(res, 60)); - const active = Array.from(document.querySelectorAll('[data-testid^="g7le-init-action-dropline-"]')) + const active = Array.from(document.querySelectorAll('[data-testid^="g7le-init-action-self-dropline-"]')) .filter((d) => d.getAttribute('data-active') === 'true') .map((d) => d.getAttribute('data-testid')); card.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt })); return active; - }); - // 카드1 아래 절반 → 끝(end) 삽입 지점이 활성. - expect(activeDuringOver).toContain('g7le-init-action-dropline-end'); + }, base); + // 내가 추가한 두 번째 카드 아래 절반 → 끝(end) 삽입 지점이 활성. + expect(activeDuringOver).toContain('g7le-init-action-self-dropline-end'); - // 드롭 결과 — 순서가 뒤집힌다(카드0이 끝으로). + // 드롭 결과 — 마지막 두 카드가 서로 바뀌고 기존 항목은 그대로. + const swapped = [...before.slice(0, base), before[base + 1], before[base]]; await expect .poll(async () => (await cardTitles(page)).join('|')) - .toBe([before[1], before[0]].join('|')); + .toBe(swapped.join('|')); // 드래그 종료 후 삽입선은 모두 비활성으로 복원. - await expect(page.locator('[data-testid="g7le-init-action-dropline-end"]')).toHaveAttribute('data-active', 'false'); + await expect(page.locator('[data-testid="g7le-init-action-self-dropline-end"]')).toHaveAttribute('data-active', 'false'); }); test('연속 드래그 — 누적 순서 정합(다시 원래 순서로)', async ({ page }) => { const token = issueToken('core.templates.layouts.edit'); await authenticatePage(page, token); await openInitActionsTab(page); - await addTwoActions(page); + const base = await addTwoActions(page); const initial = await cardTitles(page); - // 1차: 0 → 끝(뒤집힘). - await dragActionCard(page, 0, 1); - await expect.poll(async () => (await cardTitles(page)).join('|')).toBe([initial[1], initial[0]].join('|')); - // 2차: 다시 0 → 끝(원래 순서로 복원) — 누적 정합. - await dragActionCard(page, 0, 1); + const swapped = [...initial.slice(0, base), initial[base + 1], initial[base]]; + // 1차: 추가한 첫 카드 → 끝(뒤집힘). + await dragActionCard(page, base, base + 1); + await expect.poll(async () => (await cardTitles(page)).join('|')).toBe(swapped.join('|')); + // 2차: 같은 자리로 다시 → 원래 순서로 복원 — 누적 정합. + await dragActionCard(page, base, base + 1); await expect.poll(async () => (await cardTitles(page)).join('|')).toBe(initial.join('|')); }); }); diff --git a/tests/Playwright/specs/layout-editor/array-editor.spec.ts b/tests/Playwright/specs/layout-editor/array-editor.spec.ts index 5c984bf0..037e6e5a 100644 --- a/tests/Playwright/specs/layout-editor/array-editor.spec.ts +++ b/tests/Playwright/specs/layout-editor/array-editor.spec.ts @@ -15,10 +15,12 @@ * 잠그고, 본 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 + * @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 { const token = issueToken('core.templates.layouts.edit'); @@ -58,8 +60,20 @@ async function openPropsTab(page: Page): Promise { await page.waitForTimeout(200); } -/** content root(Div) 안에 TabNavigation 을 추가하고 그 path 반환. */ -async function addTabNavigation(page: Page): Promise { +/** + * content root(Div) 안에 TabNavigation 을 추가하고 그 path 반환. + * + * @param page Playwright page + * @param sandbox true 면 시드 화면의 전용 컨테이너(고정 id)를 대상으로 한다 (저장 spec 전용). + */ +async function addTabNavigation(page: Page, sandbox = false): Promise { + 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(() => @@ -81,10 +95,22 @@ async function addTabNavigation(page: Page): Promise { } } 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 { 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]')) @@ -92,7 +118,7 @@ async function addTabNavigation(page: Page): Promise { .filter((p) => re.test(p)) .map((p) => parseInt(p.split('.').pop() as string, 10)); return C + '.children.' + Math.max(...idxs); - }, containerPath!); + }, containerPath); } test.describe('@layout-editor array 노드 에디터(빌트인 ARRAY-PROP)', () => { @@ -128,10 +154,14 @@ test.describe('@layout-editor array 노드 에디터(빌트인 ARRAY-PROP)', () 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); - const navPath = await addTabNavigation(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(); @@ -160,20 +190,12 @@ test.describe('@layout-editor array 노드 에디터(빌트인 ARRAY-PROP)', () const saveRes = await savePromise; expect(saveRes.status()).toBe(200); - // 정리 — 테스트가 추가한 TabNavigation 을 삭제하고 다시 저장(잔여물 누적 방지). - expect(await selectByPath(page, navPath)).toBe(true); - await page.getByTestId('g7le-overlay-info-button').click(); - await page.waitForSelector('[data-testid="g7le-context-menu-delete"]', { timeout: 5_000 }); - await page.getByTestId('g7le-context-menu-delete').click(); - await page.waitForTimeout(400); - const cleanupPromise = page.waitForResponse( - (r) => - /\/api\/admin\/templates\/sirsoft-basic\/layouts\//.test(r.url()) && - r.request().method() === 'PUT', - { timeout: 15_000 }, + // 저장한 노드가 캔버스에 남아 있어야 한다(저장이 문서를 되돌리지 않았음). + const persisted = await page.evaluate( + (p) => !!document.querySelector(`[data-editor-path="${p}"]`), + navPath, ); - await page.getByTestId('g7le-toolbar-save').click(); - expect((await cleanupPromise).status()).toBe(200); + expect(persisted, '저장 후 추가한 TabNavigation 이 캔버스에서 사라졌다').toBe(true); }); // array kind `defaultItems` 시드. @@ -193,12 +215,45 @@ test.describe('@layout-editor array 노드 에디터(빌트인 ARRAY-PROP)', () { timeout: 20_000 }, ); - // 가시 노드(BarChart) 선택 → 아래 삽입 팔레트 → IconSelect 추가. - const anchorPath = await page.evaluate( - () => document.querySelector('[data-editor-name="BarChart"]')?.getAttribute('data-editor-path') ?? null, + // 가시 노드 선택 → 아래 삽입 팔레트 → 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(anchorPath).toBeTruthy(); - expect(await selectByPath(page, anchorPath!)).toBe(true); + 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(); diff --git a/tests/Playwright/specs/layout-editor/children-list-editor.spec.ts b/tests/Playwright/specs/layout-editor/children-list-editor.spec.ts index 0b1da884..ad24f51a 100644 --- a/tests/Playwright/specs/layout-editor/children-list-editor.spec.ts +++ b/tests/Playwright/specs/layout-editor/children-list-editor.spec.ts @@ -15,10 +15,12 @@ * CRUD round-trip 을 잠그고, 본 E2E 는 구조 편집(추가/정렬/저장)을 브라우저로 확인한다. * * @scenario children_node_editor + add_remove_move + live_persist - * @effects property_modal_dispatches_children_node_editor_in_props_tab_by_kind_not_name, add_item_appends_childcomponent_defaultnode_to_children, move_up_down_swaps_adjacent_children_with_boundary_guard, live_add_li_edit_text_reorder_save_persists_to_user_page, keyboard_arrowup_escape_escalates_selection_to_parent, overlapping_child_selected_type_chip_escalates_to_parent + * @effects property_modal_dispatches_children_node_editor_in_props_tab_by_kind_not_name, add_item_appends_childcomponent_defaultnode_to_children, move_up_down_swaps_adjacent_children_with_boundary_guard, live_add_li_edit_text_reorder_save_persists_to_user_page, keyboard_arrowup_escape_escalates_selection_to_parent, overlapping_child_selected_type_chip_escalates_to_parent, editor_save_specs_target_sandbox_layout_not_product_layout, editor_save_persists_and_survives_reload_on_sandbox */ 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 { const token = issueToken('core.templates.layouts.edit'); @@ -60,8 +62,20 @@ async function openPropsTab(page: Page): Promise { await page.waitForTimeout(200); } -/** content root(Div) path 를 찾아 Ul 을 추가하고 그 path 반환. */ -async function addUl(page: Page): Promise { +/** + * content root(Div) path 를 찾아 Ul 을 추가하고 그 path 반환. + * + * @param page Playwright page + * @param sandbox true 면 시드 화면의 전용 컨테이너(고정 id)를 대상으로 한다 (저장 spec 전용). + */ +async function addUl(page: Page, sandbox = false): Promise { + if (sandbox) { + const root = await editorPath(page, '', SANDBOX_ROOT_ID); + expect(await selectByPath(page, root)).toBe(true); + + return appendUlTo(page, root); + } + // home content 영역 — 편집 가능한(ⓘ 컨텍스트 메뉴가 뜨는) 컨테이너 Div 를 찾아 그 안에 // Ul 추가. 첫 Div 고정 선택은 깨지기 쉽다 — 루트/베이스 잠금 노드는 선택돼도 ⓘ 가 // 표시되지 않으므로(레이아웃 구조 변화에 따라 첫 Div 가 잠금 노드가 됨), 후보를 @@ -86,10 +100,22 @@ async function addUl(page: Page): Promise { } } expect(containerPath).toBeTruthy(); + + return appendUlTo(page, containerPath!); +} + +/** + * 선택된 컨테이너에 팔레트로 Ul 을 추가하고 새 노드의 path 를 반환합니다. + * + * @param page Playwright page + * @param containerPath 선택 완료된 컨테이너의 editor path + */ +async function appendUlTo(page: Page, containerPath: string): Promise { await page.getByTestId('g7le-toolbar-add-element').click(); await page.waitForSelector('[data-testid="g7le-palette-item-Ul"]', { timeout: 10_000 }); await page.getByTestId('g7le-palette-item-Ul').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]')) @@ -97,7 +123,7 @@ async function addUl(page: Page): Promise { .filter((p) => re.test(p)) .map((p) => parseInt(p.split('.').pop() as string, 10)); return C + '.children.' + Math.max(...idxs); - }, containerPath!); + }, containerPath); } test.describe('@layout-editor children 노드 에디터(목록 빌트인)', () => { @@ -137,11 +163,15 @@ test.describe('@layout-editor children 노드 에디터(목록 빌트인)', () = await expect(page.getByTestId('g7le-children-row-1')).toBeVisible(); }); + // 저장(PUT)하는 테스트는 편집 결과가 그대로 영속되므로 제품 화면(home)이 아니라 E2E 전용 + // 시드 화면(e2e_sandbox)을 대상으로 한다. 이전에는 "추가한 Ul 삭제 후 재저장" 으로 원복했으나, + // 원복 자체가 또 한 번의 저장이라 실패하면 잔여물이 남았다. 시드 화면은 globalSetup 이 매 실행 + // fixture 원본으로 덮어쓰므로 원복 절차가 필요 없다. test('children 편집 후 저장 → PUT 200 + reload 영속', async ({ page }) => { - test.setTimeout(60_000); // 후보 순회 + 저장 + reload 합산 — 기본 30s 부족 + test.setTimeout(60_000); // 저장 + reload 합산 — 기본 30s 부족 - await gotoEditor(page); - const ulPath = await addUl(page); + await gotoEditor(page, sandboxRouteParam()); + const ulPath = await addUl(page, true); expect(await selectByPath(page, ulPath)).toBe(true); await openPropsTab(page); await page.getByTestId('g7le-children-add').click(); @@ -167,19 +197,17 @@ test.describe('@layout-editor children 노드 에디터(목록 빌트인)', () = const saveRes = await savePromise; expect(saveRes.status()).toBe(200); - // 정리 — 테스트가 추가한 Ul 을 삭제하고 다시 저장해 라이브 레이아웃에 잔여물을 - // 남기지 않는다(반복 실행 시 home 에 Ul 이 누적되던 문제). - expect(await selectByPath(page, ulPath)).toBe(true); - await page.getByTestId('g7le-overlay-info-button').click(); - await page.waitForSelector('[data-testid="g7le-context-menu-delete"]', { timeout: 5_000 }); - await page.getByTestId('g7le-context-menu-delete').click(); - await page.waitForTimeout(400); - const cleanupPromise = page.waitForResponse( - (r) => /\/api\/admin\/templates\/sirsoft-basic\/layouts\//.test(r.url()) && r.request().method() === 'PUT', - { timeout: 15_000 }, + // 새로고침 후에도 추가한 Ul 이 남아 있어야 한다(저장 영속 검증). + await page.reload(); + await page.waitForSelector('[data-testid="g7le-preview-frame"]', { timeout: 30_000 }); + await page.waitForFunction(() => document.querySelectorAll('[data-editor-path]').length > 0, { + timeout: 20_000, + }); + const persisted = await page.evaluate( + (p) => !!document.querySelector(`[data-editor-path="${p}"]`), + ulPath, ); - await page.getByTestId('g7le-toolbar-save').click(); - expect((await cleanupPromise).status()).toBe(200); + expect(persisted, '저장한 Ul 이 새로고침 후 사라졌다').toBe(true); }); // 겹친 부모 선택 — 캔버스 클릭은 늘 가장 깊은 자식을 잡으므로(closest), 부모/자식 크기가 diff --git a/tests/Playwright/specs/layout-editor/condition-preset-variant-defense.spec.ts b/tests/Playwright/specs/layout-editor/condition-preset-variant-defense.spec.ts index 42cde1c6..ce9db17f 100644 --- a/tests/Playwright/specs/layout-editor/condition-preset-variant-defense.spec.ts +++ b/tests/Playwright/specs/layout-editor/condition-preset-variant-defense.spec.ts @@ -26,6 +26,12 @@ import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth' type PwPage = import('@playwright/test').Page; +// 관리자 템플릿의 라우트는 모두 `*/admin/...` 프리픽스를 갖는다. 예전에는 '/' 를 넘겼는데 +// 그런 라우트가 없어 레이아웃이 해석되지 않고 캔버스(g7le-preview-frame)가 끝내 뜨지 않았다. +// `*/admin` 은 존재하지만 `redirect` 전용(레이아웃 없음)이라 역시 렌더할 대상이 없다 — +// 레이아웃을 가진 라우트를 지목해야 한다. +const ADMIN_ROUTE = '*/admin/dashboard'; + async function openEditor(page: PwPage, template: string, route: string): Promise { await page.goto(`/admin/layout-editor/${template}?route=${encodeURIComponent(route)}`); await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); @@ -107,7 +113,7 @@ test.describe('@layout-editor 표시조건 변종 방어 인프라 (단계 0)', test('P2/P3 operator 가 served editor-spec 에 노출 + 숫자/배열 패턴 인식', async ({ page }) => { const token = issueToken('core.templates.layouts.edit'); await authenticatePage(page, token); - await openEditor(page, 'sirsoft-admin_basic', '/'); + await openEditor(page, 'sirsoft-admin_basic', ADMIN_ROUTE); // served editor-spec 의 conditionRecipes 에 단계 2 operator 가 모두 노출돼야 한다 // (template:update 로 활성 디렉토리 반영 회귀 가드). @@ -133,7 +139,7 @@ test.describe('@layout-editor 표시조건 변종 방어 인프라 (단계 0)', test('P5 valueIsTrue/valueIsFalse 가 served editor-spec 에 노출', async ({ page }) => { const token = issueToken('core.templates.layouts.edit'); await authenticatePage(page, token); - await openEditor(page, 'sirsoft-admin_basic', '/'); + await openEditor(page, 'sirsoft-admin_basic', ADMIN_ROUTE); // served editor-spec 의 conditionRecipes 에 단계 3 명시비교 operator 가 노출돼야 한다 // (template:update 로 활성 디렉토리 반영 회귀 가드). 양 템플릿 동기. @@ -163,7 +169,7 @@ test.describe('@layout-editor 표시조건 변종 방어 인프라 (단계 0)', test('단계 4 비교 패턴 operator 가 served editor-spec 에 노출', async ({ page }) => { const token = issueToken('core.templates.layouts.edit'); await authenticatePage(page, token); - await openEditor(page, 'sirsoft-admin_basic', '/'); + await openEditor(page, 'sirsoft-admin_basic', ADMIN_ROUTE); // served editor-spec 의 conditionRecipes 에 단계 4 비교 패턴 net-new operator 가 // 노출돼야 한다(template:update 활성 반영 회귀 가드). 양 템플릿 동기. @@ -194,7 +200,7 @@ test.describe('@layout-editor 표시조건 변종 방어 인프라 (단계 0)', test('60% 게이트 토대 프리셋 18종이 양 템플릿 served editor-spec 에 전부 노출', async ({ page }) => { const token = issueToken('core.templates.layouts.edit'); await authenticatePage(page, token); - await openEditor(page, 'sirsoft-admin_basic', '/'); + await openEditor(page, 'sirsoft-admin_basic', ADMIN_ROUTE); // 단계 1~4 가 추가한 범용 프리셋(P1~P5 + 비교 패턴) 전부가 양 템플릿 활성 디렉토리 // editor-spec 에 반영돼 있어야 60%+ 커버리지가 라이브에서 성립한다(template:update 회귀 가드). diff --git a/tests/Playwright/specs/layout-editor/data-builtins.spec.ts b/tests/Playwright/specs/layout-editor/data-builtins.spec.ts index b3d4a84b..0430c11a 100644 --- a/tests/Playwright/specs/layout-editor/data-builtins.spec.ts +++ b/tests/Playwright/specs/layout-editor/data-builtins.spec.ts @@ -14,14 +14,19 @@ * array_editor_number_widget_writes_numeric_value, * array_group_renders_multiple_array_editors_per_group, * live_donut_data_item_edit_save_persists_to_user_page, - * live_barchart_dataset_edit_save_persists_to_user_page + * live_barchart_dataset_edit_save_persists_to_user_page, admin_editor_save_specs_target_admin_sandbox_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'; const TEMPLATE = 'sirsoft-admin_basic'; -async function gotoEditor(page: Page, route = '%2Fadmin'): Promise { +// 관리자 템플릿의 라우트는 모두 `*/admin/...` 프리픽스를 갖는다 — '/admin' 은 존재하지 않고 +// `*/admin` 은 `redirect` 전용(레이아웃 없음)이라, 둘 다 캔버스(g7le-preview-frame)가 뜨지 않는다. +// 차트 빌트인(DonutChart/BarChart)이 있는 대시보드 라우트를 지목한다. +async function gotoEditor(page: Page, route = '*%2Fadmin%2Fdashboard'): Promise { const token = issueToken('core.templates.layouts.edit'); await authenticatePage(page, token); await page.goto(`/admin/layout-editor/${TEMPLATE}?route=${route}`); @@ -33,6 +38,76 @@ async function gotoEditor(page: Page, route = '%2Fadmin'): Promise { } async function selectByPath(page: Page, path: string): Promise { + const dispatchSynthetic = () => + 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); + + /** + * 선택 오버레이가 **그 노드에 대해** 떠 있는지 판정. + * + * 오버레이 존재만 보면 다른 노드가 선택된 상태를 통과시킨다. 오버레이의 타입 라벨이 대상 노드의 + * `data-editor-name` 과 일치하는지까지 확인한다 (라벨은 방향 표시 `↑` 장식을 앞에 두므로 포함 비교). + */ + const overlayMatches = async (timeout: number): Promise => { + const appeared = await page + .waitForSelector('[data-testid="g7le-overlay-info-button"]', { timeout }) + .then(() => true) + .catch(() => false); + if (!appeared) return false; + + return page.evaluate((p) => { + const node = document.querySelector(`[data-editor-path="${p}"]`); + const label = document.querySelector('[data-testid="g7le-overlay-type-label"]'); + if (!node || !label) return false; + const name = node.getAttribute('data-editor-name'); + + return !!name && (label.textContent ?? '').includes(name); + }, path); + }; + + // 팔레트로 삽입한 직후에는 편집기가 새 노드를 이미 선택해 둔다. 그 상태에서 다시 클릭하면 + // 차트처럼 canvas 를 품은 컴포넌트는 자식이 좌상단을 가려 선택이 오히려 풀린다 — + // 이미 맞게 선택돼 있으면 손대지 않는다. (선점 확인이라 대기는 짧게) + if (await overlayMatches(300)) return true; + + await dispatchSynthetic(); + if (await overlayMatches(3_000)) return true; + + // 노드 박스를 직접 클릭하면 자식·부모 Div 가 좌상단을 가려 엉뚱한 노드가 선택된다 + // (차트처럼 내부가 canvas 인 컴포넌트에서 실제로 그랬다 — 라벨이 BarChart 대신 Div). + // 노드 전용 드래그 핸들은 그 노드만 지목하므로 이걸로 선택한다. + const handle = page.locator(`[data-dnd-handle-path="${path}"]`); + if ((await handle.count()) > 0) { + await handle.first().click({ force: true, timeout: 3_000 }).catch(() => { /* 아래 판정에 위임 */ }); + if (await overlayMatches(3_000)) return true; + } + + // 마지막 수단 — 실제 마우스 클릭 (hit-testing 경유). + await page + .locator(`[data-editor-path="${path}"]`) + .first() + .click({ position: { x: 4, y: 4 }, force: true, timeout: 3_000 }) + .catch(() => { /* 클릭 자체가 불가하면 아래 판정에서 false */ }); + + return overlayMatches(3_000); +} + +/** + * 컨테이너 후보 탐색용 빠른 선택 시도. + * + * `selectByPath` 는 3단계 폴백을 거쳐 한 번에 최대 6초 이상 쓴다 — 후보를 15개 순회하면 + * 테스트 타임아웃(30초)을 넘겨 페이지가 통째로 닫힌다. 순회에는 합성 클릭 1회 + 짧은 대기만 쓴다. + */ +async function trySelectQuick(page: Page, path: string): Promise { await page.evaluate((p) => { const el = document.querySelector(`[data-editor-path="${p}"]`); if (!el) return; @@ -44,8 +119,9 @@ async function selectByPath(page: Page, path: string): Promise { el.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX: cx, clientY: cy })); } }, path); + return page - .waitForSelector('[data-testid="g7le-overlay-info-button"]', { timeout: 5_000 }) + .waitForSelector('[data-testid="g7le-overlay-info-button"]', { timeout: 1_200 }) .then(() => true) .catch(() => false); } @@ -59,34 +135,85 @@ async function openPropsTab(page: Page): Promise { await page.waitForTimeout(200); } -/** content root(Div) 안에 컴포넌트를 추가하고 그 path 반환. */ -async function addComponent(page: Page, name: string): Promise { - const containerPath = await page.evaluate(() => { - const els = Array.from(document.querySelectorAll('[data-editor-name="Div"]')); - return els[0]?.getAttribute('data-editor-path') ?? null; - }); - expect(containerPath).toBeTruthy(); - expect(await selectByPath(page, containerPath!)).toBe(true); - await page.getByTestId('g7le-toolbar-add-element').click(); - await page.waitForSelector(`[data-testid="g7le-palette-item-${name}"]`, { timeout: 10_000 }); - await page.getByTestId(`g7le-palette-item-${name}`).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!); +/** + * content root(Div) 안에 컴포넌트를 추가하고 그 path 반환. + * + * 예전에는 첫 번째 `data-editor-name="Div"` 를 무조건 컨테이너로 삼았는데, 그 노드가 편집기에서 + * 선택 불가(잠금 영역 등)면 곧바로 실패했다. 클래스나 순번으로 지목하지 않고 **실제로 선택되고 + * 팔레트에 요청 컴포넌트가 뜨는** Div 를 찾는다. + * + * @param page Playwright page + * @param name 추가할 컴포넌트 이름 + * @param sandbox true 면 시드 화면의 전용 컨테이너(고정 id)만 후보로 둔다 (저장 spec 전용). + */ +async function addComponent(page: Page, name: string, sandbox = false): Promise { + const candidates = sandbox + ? [await editorPath(page, '', SANDBOX_ROOT_ID)] + : await page.evaluate(() => + Array.from(document.querySelectorAll('[data-editor-name="Div"]')) + .map((e) => e.getAttribute('data-editor-path') ?? '') + .filter(Boolean), + ); + expect(candidates.length, 'Div 컨테이너 후보가 있어야 함').toBeGreaterThan(0); + + /** 캔버스에 존재하는 해당 컴포넌트 노드 path 목록 */ + const nodesOf = (componentName: string) => + page.evaluate( + (n) => + Array.from(document.querySelectorAll(`[data-editor-name="${n}"]`)) + .map((e) => e.getAttribute('data-editor-path') ?? '') + .filter(Boolean), + componentName, + ); + + const before = new Set(await nodesOf(name)); + + // 후보를 순회하며 **실제로 삽입되는** 컨테이너를 찾는다. 팔레트에 항목이 보이는 것만으로는 + // 부족하다 — 선택이 풀린 상태면 클릭해도 삽입되지 않는다. 삽입 성공은 노드가 실제로 + // 늘었는지로 판정한다. + let addedPath: string | null = null; + for (const candidate of candidates.slice(0, 15)) { + if (!(await trySelectQuick(page, candidate))) continue; + + await page.getByTestId('g7le-toolbar-add-element').click(); + const hasItem = await page + .waitForSelector(`[data-testid="g7le-palette-item-${name}"]`, { timeout: 4_000 }) + .then(() => true) + .catch(() => false); + + if (hasItem) { + await page.getByTestId(`g7le-palette-item-${name}`).click(); + await page.waitForTimeout(600); + const fresh = (await nodesOf(name)).filter((p) => !before.has(p)); + if (fresh.length > 0) { + addedPath = fresh.sort()[fresh.length - 1]; + break; + } + } + + // 이 후보로는 삽입되지 않았다 — 팔레트를 토글로 닫고 다음 후보로. + await page.getByTestId('g7le-toolbar-add-element').click().catch(() => { /* 이미 닫힘 */ }); + await page.waitForTimeout(200); + } + + expect(addedPath, `${name} 을 삽입할 수 있는 컨테이너를 찾지 못했다`).toBeTruthy(); + + return addedPath!; } +// 삽입 컨테이너를 시드 화면의 고정 id 컨테이너로 고정한다. +// +// 제품 대시보드에서는 `addComponent` 가 Div 후보를 순회해 "삽입되는 곳" 에 꽂는데, 그 위치가 +// 모듈이 주입한 잠금(확장) 서브트리 안이면 선택이 조상 Div 로 escalate 되어 노드 자체를 지목할 수 +// 없다. 실측(2026-07-30): 대시보드 삽입 시 BarChart 선택 라벨이 `↑Div` + ⓘ 미표시였고, 같은 절차를 +// 시드 컨테이너에서 수행하면 `↑BarChart` + ⓘ 표시 + array-group 에디터(labels/datasets) 3개 모두 +// 마운트된다. 컴포넌트 결함이 아니라 삽입 위치 문제였다. test.describe('@layout-editor 데이터 정의 빌트인(8-b)', () => { test('DonutChart 선택 시 array 에디터(data)가 속성 탭에 마운트되고 value(number) 편집 반영', async ({ page, }) => { - await gotoEditor(page); - const path = await addComponent(page, 'DonutChart'); + await gotoEditor(page, sandboxRouteParam(TEMPLATE)); + const path = await addComponent(page, 'DonutChart', true); expect(await selectByPath(page, path)).toBe(true); await openPropsTab(page); await expect(page.getByTestId('g7le-array-editor')).toBeVisible(); @@ -104,8 +231,8 @@ test.describe('@layout-editor 데이터 정의 빌트인(8-b)', () => { test('BarChart 선택 시 array-group 에디터(labels+datasets)가 두 그룹으로 마운트', async ({ page, }) => { - await gotoEditor(page); - const path = await addComponent(page, 'BarChart'); + await gotoEditor(page, sandboxRouteParam(TEMPLATE)); + const path = await addComponent(page, 'BarChart', true); expect(await selectByPath(page, path)).toBe(true); await openPropsTab(page); await expect(page.getByTestId('g7le-array-group-editor')).toBeVisible(); @@ -113,9 +240,12 @@ test.describe('@layout-editor 데이터 정의 빌트인(8-b)', () => { await expect(page.getByTestId('g7le-array-group-datasets')).toBeVisible(); }); + // 저장(PUT)하는 테스트는 편집 결과가 그대로 영속되므로 제품 화면(admin_dashboard)이 아니라 + // E2E 전용 시드 화면(e2e_sandbox)을 대상으로 한다. 제품 화면에 저장하던 동안 관리자 대시보드에 + // 빈 DonutChart 5개가 누적됐다. 시드 화면은 globalSetup 이 매 실행 fixture 원본으로 덮어쓴다. test('DonutChart 데이터 편집 후 저장 → PUT 200', async ({ page }) => { - await gotoEditor(page); - const path = await addComponent(page, 'DonutChart'); + await gotoEditor(page, sandboxRouteParam(TEMPLATE)); + const path = await addComponent(page, 'DonutChart', true); expect(await selectByPath(page, path)).toBe(true); await openPropsTab(page); await expect(page.getByTestId('g7le-array-editor')).toBeVisible(); diff --git a/tests/Playwright/specs/layout-editor/empty-container-form-propcontrols.spec.ts b/tests/Playwright/specs/layout-editor/empty-container-form-propcontrols.spec.ts index c6cd55e3..5aee8938 100644 --- a/tests/Playwright/specs/layout-editor/empty-container-form-propcontrols.spec.ts +++ b/tests/Playwright/specs/layout-editor/empty-container-form-propcontrols.spec.ts @@ -21,6 +21,7 @@ */ 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 { const token = issueToken('core.templates.layouts.edit'); @@ -74,7 +75,9 @@ async function clickPaletteItem(page: Page, name: string): Promise { /** 검증 컨테이너(빈 Div) 를 home content root 안에 추가하고 그 path 를 반환. */ async function addEmptyDiv(page: Page): Promise { - const contentRoot = '2.children.5.children.0.children.0'; + // 첫 세그먼트는 베이스 레이아웃 루트 인덱스라 베이스에 컴포넌트가 추가되면 밀린다 — + // 리터럴 대신 본문 루트 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'); diff --git a/tests/Playwright/specs/layout-editor/inline-edit-i18n.spec.ts b/tests/Playwright/specs/layout-editor/inline-edit-i18n.spec.ts index c650fa19..db1f88b0 100644 --- a/tests/Playwright/specs/layout-editor/inline-edit-i18n.spec.ts +++ b/tests/Playwright/specs/layout-editor/inline-edit-i18n.spec.ts @@ -13,9 +13,15 @@ */ import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth'; import type { Page } from '@playwright/test'; +import { editorPath as resolveEditorPath } from '../../fixtures/layout-editor'; -const CARD = '2.children.5.children.0.children.0.children.1'; // 로그인 카드(Div) -const HEADING = `${CARD}.children.0`; // 카드 안 제목(텍스트 노드) +/** 로그인 카드(Div) — 본문 루트 기준 상대 경로 */ +const CARD_REL = 'children.5.children.0.children.0.children.1'; + +// 절대 경로의 첫 세그먼트는 베이스 레이아웃 루트 인덱스라 베이스에 컴포넌트가 추가되면 밀린다. +// 리터럴로 두지 않고 openEditorLogin 이 본문 루트 id 로 해석해 채운다 (사용처는 그대로 읽는다). +let CARD = ''; +let HEADING = ''; // 카드 안 제목(텍스트 노드) async function openEditorLogin(page: Page): Promise { const token = issueToken('core.templates.layouts.edit'); @@ -27,6 +33,9 @@ async function openEditorLogin(page: Page): Promise { () => document.querySelectorAll('[data-editor-path]').length > 0, { timeout: 20_000 }, ); + + CARD = await resolveEditorPath(page, CARD_REL); + HEADING = `${CARD}.children.0`; } /** diff --git a/tests/Playwright/specs/layout-editor/iteration-edit-save-return-inline.spec.ts b/tests/Playwright/specs/layout-editor/iteration-edit-save-return-inline.spec.ts index 5916fd87..c2330aed 100644 --- a/tests/Playwright/specs/layout-editor/iteration-edit-save-return-inline.spec.ts +++ b/tests/Playwright/specs/layout-editor/iteration-edit-save-return-inline.spec.ts @@ -8,16 +8,33 @@ * @effects url_direct_entry_renders_iteration_mode_with_host + exit_iteration_edit_returns_to_host_route + iteration_save_invalidates_host_route_cache_and_busts_get + double_click_plain_text_enters_inline_edit + data_bound_node_shows_data_area_notice */ import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth'; +import { editorPath as resolveEditorPath } from '../../fixtures/layout-editor'; const TEMPLATE = 'sirsoft-basic'; const EDITOR_BASE = `/admin/layout-editor/${TEMPLATE}`; -// 인기글(board/popular) 의 게시글 카드 iteration 원본 노드 path (D-31~D-34 실측 기준). -const ITERATION_SOURCE = '2.children.5.children.0.children.0.children.2.children.0.children.1'; const HOST = 'board/popular'; +const HOST_ROUTE = '/boards/popular'; +// 인기글(board/popular) 의 게시글 카드 iteration 원본 노드 — 본문 루트 기준 상대 경로. +// 절대 경로의 첫 세그먼트는 베이스 레이아웃 루트 인덱스라 베이스에 컴포넌트가 추가되면 밀린다. +const ITERATION_SOURCE_REL = 'children.5.children.0.children.0.children.2.children.0.children.1'; + +/** 반복 항목 편집 진입에 쓸 절대 노드 path (호스트 라우트에서 실측해 채운다) */ +let ITERATION_SOURCE = ''; async function gotoIterationEdit(page: import('@playwright/test').Page): Promise { const token = issueToken('core.templates.layouts.edit'); await authenticatePage(page, token); + + // 노드 path 가 URL 파라미터라 진입 전에 알아야 한다. 호스트 라우트를 먼저 열어 본문 루트 + // 인덱스를 실측한 뒤, 그 절대 경로로 반복 항목 편집 URL 을 만든다. + await page.goto(`${EDITOR_BASE}?route=${encodeURIComponent(HOST_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, + }); + ITERATION_SOURCE = await resolveEditorPath(page, ITERATION_SOURCE_REL); + const url = `${EDITOR_BASE}?edit=__iteration__/${encodeURIComponent(ITERATION_SOURCE)}&host=${encodeURIComponent(HOST)}`; await page.goto(url); await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); diff --git a/tests/Playwright/specs/layout-editor/locked-area-insertion.spec.ts b/tests/Playwright/specs/layout-editor/locked-area-insertion.spec.ts index 9a1fe128..0c7f5f60 100644 --- a/tests/Playwright/specs/layout-editor/locked-area-insertion.spec.ts +++ b/tests/Playwright/specs/layout-editor/locked-area-insertion.spec.ts @@ -27,7 +27,12 @@ async function openRegisterEditor(page: Page): Promise { await authenticatePage(page, token); await page.goto('/admin/layout-editor/sirsoft-basic?route=%2Fregister'); await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); - await page.waitForSelector('[data-testid="g7le-preview-frame"] [data-editor-path]', { timeout: 30_000 }); + // state 기본값은 'visible' 인데 프레임 하위 첫 노드는 빈 앵커(header_currency_inject_anchor)나 + // `hidden` 클래스 요소일 수 있어 끝내 visible 이 되지 않는다 — 렌더 완료 판정은 존재로 한다. + await page.waitForSelector('[data-testid="g7le-preview-frame"] [data-editor-path]', { + state: 'attached', + timeout: 30_000, + }); } async function openHomeEditor(page: Page): Promise { @@ -35,7 +40,12 @@ async function openHomeEditor(page: Page): Promise { await authenticatePage(page, token); 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"] [data-editor-path]', { timeout: 30_000 }); + // state 기본값은 'visible' 인데 프레임 하위 첫 노드는 빈 앵커(header_currency_inject_anchor)나 + // `hidden` 클래스 요소일 수 있어 끝내 visible 이 되지 않는다 — 렌더 완료 판정은 존재로 한다. + await page.waitForSelector('[data-testid="g7le-preview-frame"] [data-editor-path]', { + state: 'attached', + timeout: 30_000, + }); } /** 합성 마우스 시퀀스로 노드 선택(드래그 핸들이 실제 click 을 가로채므로 dispatch). */ @@ -44,9 +54,18 @@ async function selectByText(page: Page, matcher: string, maxLen: number): Promis async ({ matcher, maxLen }) => { const frame = document.querySelector('[data-testid="g7le-preview-frame"]')!; const re = new RegExp(matcher); + // maxLen 제약을 반드시 함께 적용한다. textContent 는 자손 텍스트를 모두 포함하므로 + // 길이 제한 없이는 화면 전체를 감싼 컨테이너가 먼저 걸려(진입점이 아니라 폼 전체) 선택 + // 결과가 엉뚱해진다 — 실측에서 회원가입 폼 컨테이너가 잡혔다. const cand = (Array.from(frame.querySelectorAll('[data-editor-path]')) as HTMLElement[]) .map((e) => ({ el: e, r: e.getBoundingClientRect() })) - .filter((x) => re.test(x.el.textContent || '') && x.r.width > 0 && x.r.height > 0 && x.r.top >= 60 && x.r.top < window.innerHeight - 80); + .filter( + (x) => + re.test(x.el.textContent || '') && + (x.el.textContent || '').trim().length <= maxLen && + x.r.width > 0 && + x.r.height > 0, + ); const pick = cand[cand.length - 1]; if (!pick) return { found: false }; pick.el.scrollIntoView({ block: 'center' }); diff --git a/tests/Playwright/specs/layout-editor/page-state-switcher.spec.ts b/tests/Playwright/specs/layout-editor/page-state-switcher.spec.ts index efa1f936..885dbec5 100644 --- a/tests/Playwright/specs/layout-editor/page-state-switcher.spec.ts +++ b/tests/Playwright/specs/layout-editor/page-state-switcher.spec.ts @@ -98,17 +98,17 @@ test.describe('@layout-editor 페이지 상태 토글 (S6-3)', () => { const frame = page.getByTestId('g7le-preview-frame'); // 기본 password_entry → 비밀번호 확인 섹션 표시 - await expect(frame.getByText('Please enter your password to edit your profile.').first()).toBeVisible({ + await expect(frame.getByText(/내 정보를 수정하려면 비밀번호를 입력해주세요\.|Please enter your password to edit your profile\./).first()).toBeVisible({ timeout: 15_000, }); // actual_edit 전환 → 비밀번호 섹션 사라지고 정보 수정 폼(필드) 표시 await select.selectOption('actual_edit'); await expect(page.getByTestId('g7le-state-switcher')).toHaveAttribute('data-active-state', 'actual_edit', { timeout: 5_000 }); - await expect(frame.getByText('Please enter your password to edit your profile.')).toHaveCount(0, { timeout: 15_000 }); + await expect(frame.getByText(/내 정보를 수정하려면 비밀번호를 입력해주세요\.|Please enter your password to edit your profile\./)).toHaveCount(0, { timeout: 15_000 }); // 신규 상태 라벨이 raw 키가 아닌 친화명으로 해석된다(캐시 무효화 구멍 회귀 차단) - await expect(select.locator('option', { hasText: 'Edit profile' })).toHaveCount(1); + await expect(select.locator('option', { hasText: /정보 수정|Edit profile/ })).toHaveCount(1); await expect(select.locator('option', { hasText: 'editor.state.profile' })).toHaveCount(0); }); @@ -130,12 +130,12 @@ test.describe('@layout-editor 페이지 상태 토글 (S6-3)', () => { const frame = page.getByTestId('g7le-preview-frame'); // 기본 edit_existing → 수정 제목 - await expect(frame.getByText('Edit User Information').first()).toBeVisible({ timeout: 15_000 }); + await expect(frame.getByText(/사용자 정보 수정|Edit User Information/).first()).toBeVisible({ timeout: 15_000 }); // create_mode 전환 → route.id 제거 → 신규 작성 제목 await select.selectOption('create_mode'); await expect(page.getByTestId('g7le-state-switcher')).toHaveAttribute('data-active-state', 'create_mode', { timeout: 5_000 }); - await expect(frame.getByText('Create User').first()).toBeVisible({ timeout: 15_000 }); + await expect(frame.getByText(/사용자 등록|Create User/).first()).toBeVisible({ timeout: 15_000 }); }); // query 패치 — admin_settings 의 query.tab 변종 (미커버 발굴분). @@ -152,7 +152,7 @@ test.describe('@layout-editor 페이지 상태 토글 (S6-3)', () => { const select = page.getByTestId('g7le-state-switcher-select'); await expect(select).toBeVisible({ timeout: 15_000 }); // 2탭 변종 (General / SEO) 라벨 친화명 해석 - await expect(select.locator('option', { hasText: 'SEO tab' })).toHaveCount(1); + await expect(select.locator('option', { hasText: /SEO 탭|SEO tab/ })).toHaveCount(1); await select.selectOption('settings_seo'); await expect(page.getByTestId('g7le-state-switcher')).toHaveAttribute('data-active-state', 'settings_seo', { timeout: 5_000 }); @@ -175,8 +175,19 @@ test.describe('@layout-editor 페이지 상태 토글 (S6-3)', () => { const select = page.getByTestId('g7le-state-switcher-select'); await expect(select).toBeVisible({ timeout: 15_000 }); - // 3상태 (normal / payment_error / validation_failed) - await expect(select.locator('option')).toHaveCount(3); + + // 이 테스트가 의존하는 상태가 선택지에 있는지만 확인한다. + // + // 이전에는 `toHaveCount(3)` 으로 총 개수를 못박아 두었는데, 상태 그룹은 편집기 스펙에서 + // 계속 늘어난다 — 실제로 `summary_details_expanded` 가 추가되어 4개가 되면서(#383, + // `modules/_bundled/sirsoft-ecommerce/editor-spec.json` 의 `/shop/checkout` 그룹) 총 개수 + // 단언이 깨졌다. 개수는 이 테스트의 검증 대상(점 박힌 키 인라인 에러 노출)이 아니므로, + // 새 상태가 추가돼도 깨지지 않게 필요한 값의 존재로 판정한다. + const optionValues = await select.locator('option').evaluateAll((els) => + els.map((e) => (e as HTMLOptionElement).value), + ); + expect(optionValues).toContain('normal'); + expect(optionValues).toContain('validation_failed'); const frame = page.getByTestId('g7le-preview-frame'); diff --git a/tests/Playwright/specs/layout-editor/palette-i18n-and-globals.spec.ts b/tests/Playwright/specs/layout-editor/palette-i18n-and-globals.spec.ts index 2f8c6819..8e412b87 100644 --- a/tests/Playwright/specs/layout-editor/palette-i18n-and-globals.spec.ts +++ b/tests/Playwright/specs/layout-editor/palette-i18n-and-globals.spec.ts @@ -25,9 +25,10 @@ * "이미 로그인되어 있습니다" 토스트 미발화 가드. * * @scenario palette_i18n + global_seed + sample_global_chain + guest_only_no_currentUser + save_success - * @effects undo_history_reset + banner_visible + guest_only_no_redirect_toast + * @effects undo_history_reset + banner_visible + guest_only_no_redirect_toast, editor_save_specs_target_sandbox_layout_not_product_layout */ import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth'; +import { sandboxRouteParam } from '../../fixtures/seed-layout'; test.describe('@layout-editor palette i18n + global seed', () => { test('편집 대상 templateId 사전으로 PaletteCard entry label 해석 (결함 K)', async ({ page }) => { @@ -135,11 +136,14 @@ test.describe('@layout-editor palette i18n + global seed', () => { await expect(loginToast).toHaveCount(0); }); + // 저장(PUT)하는 테스트는 편집 결과가 그대로 영속되므로 제품 화면(home)이 아니라 E2E 전용 + // 시드 화면(e2e_sandbox)을 대상으로 한다. 시드 화면은 globalSetup 이 매 실행 fixture 원본으로 + // 덮어쓰므로 회차 간 Div 누적이 성립하지 않는다. test('저장 success kind → SaveFeedbackBanner 표시 + history reset (결함 E + I 회귀 가드)', async ({ page }) => { const token = issueToken('core.templates.layouts.edit'); await authenticatePage(page, token); - await page.goto('/admin/layout-editor/sirsoft-basic?route=%2F'); + await page.goto(`/admin/layout-editor/sirsoft-basic?route=${sandboxRouteParam()}`); await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); await page.waitForFunction( () => !document.querySelector('[data-testid="g7le-toolbar-add-element"]')?.hasAttribute('disabled'), diff --git a/tests/Playwright/specs/layout-editor/prop-i18n-text-field.spec.ts b/tests/Playwright/specs/layout-editor/prop-i18n-text-field.spec.ts index ff83e771..ecd2537b 100644 --- a/tests/Playwright/specs/layout-editor/prop-i18n-text-field.spec.ts +++ b/tests/Playwright/specs/layout-editor/prop-i18n-text-field.spec.ts @@ -29,10 +29,12 @@ * 생성·펼침 폼 노출·저장 영속(비-칩 경로)을 라이브로 잠근다. * * @scenario prop_i18n_text_field_inline_preview + prop_i18n_create_key_via_post + prop_i18n_expand_ko_en_ja + children_item_text_i18n_widget + token_persists_on_save - * @effects text_propcontrol_renders_i18n_widget_not_raw_key_input, prop_i18n_plain_input_creates_custom_key_via_post, prop_i18n_expand_shows_all_active_locales_bulk_form, children_item_text_uses_shared_i18n_widget_ssot, custom_key_token_recorded_in_prop_value_and_node_text_persists_on_put + * @effects text_propcontrol_renders_i18n_widget_not_raw_key_input, prop_i18n_plain_input_creates_custom_key_via_post, prop_i18n_expand_shows_all_active_locales_bulk_form, children_item_text_uses_shared_i18n_widget_ssot, custom_key_token_recorded_in_prop_value_and_node_text_persists_on_put, editor_save_specs_target_sandbox_layout_not_product_layout */ import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth'; import type { Page } from '@playwright/test'; +import { editorPath as resolveEditorPath } from '../../fixtures/layout-editor'; +import { SANDBOX_ROOT_ID, sandboxRouteParam } from '../../fixtures/seed-layout'; async function gotoEditor(page: Page, route = '%2F'): Promise { const token = issueToken('core.templates.layouts.edit'); @@ -78,9 +80,18 @@ async function clickPaletteItem(page: Page, name: string): Promise { await page.waitForTimeout(400); } -/** home content root 안에 빈 Div 추가 후 그 path 반환(검증된 패턴). */ -async function addEmptyDiv(page: Page): Promise { - const contentRoot = '2.children.5.children.0.children.0'; +/** + * content root 안에 빈 Div 추가 후 그 path 반환(검증된 패턴). + * + * @param page Playwright page + * @param sandbox true 면 시드 화면의 전용 컨테이너(고정 id)를 대상으로 한다 (저장 spec 전용). + */ +async function addEmptyDiv(page: Page, sandbox = false): Promise { + // 첫 세그먼트는 베이스 레이아웃 루트 인덱스라 베이스에 컴포넌트가 추가되면 밀린다 — + // 리터럴 대신 본문 루트 id 로 해석한다. 시드 화면은 컨테이너 자체가 고정 id 다. + const contentRoot = sandbox + ? await resolveEditorPath(page, '', SANDBOX_ROOT_ID) + : await resolveEditorPath(page, 'children.5.children.0.children.0'); expect(await selectByPath(page, contentRoot)).toBe(true); await openPaletteItems(page); await clickPaletteItem(page, 'Div'); @@ -139,14 +150,18 @@ test.describe('@layout-editor 텍스트 propControl 동적 다국어 (부록7 7- expect([200, 201]).toContain(resp.status()); // 키 생성 후 🌐 펼침 → ko/en/ja 일괄 편집 폼. + // 펼침부는 위 docblock 대로 `g7le-translation-*` 공용 testid 로 통합됐다 (실측 확인). await page.getByTestId('g7le-prop-i18n-inputPlaceholder-toggle').click(); - await expect(page.getByTestId('g7le-prop-i18n-inputPlaceholder-expand')).toBeVisible({ timeout: 10_000 }); - await expect(page.getByTestId('g7le-prop-i18n-inputPlaceholder-expand-input-ko')).toHaveValue('이메일을 입력하세요'); + await expect(page.getByTestId('g7le-translation-field')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId('g7le-translation-input-ko')).toHaveValue('이메일을 입력하세요'); }); + // 저장(PUT)하는 테스트는 편집 결과가 그대로 영속되므로 제품 화면(home)이 아니라 E2E 전용 + // 시드 화면(e2e_sandbox)을 대상으로 한다. 시드 화면은 globalSetup 이 매 실행 fixture 원본으로 + // 덮어쓰므로 회차 간 누적이 성립하지 않는다. test('children 항목 텍스트도 동일 i18n 위젯으로 편집(평문→키 생성) + 저장 PUT 200 영속', async ({ page }) => { - await gotoEditor(page); - const vdiv = await addEmptyDiv(page); + await gotoEditor(page, sandboxRouteParam()); + const vdiv = await addEmptyDiv(page, true); // 빈 Div 안에 Ul 추가. expect(await selectByPath(page, vdiv)).toBe(true); @@ -241,13 +256,14 @@ test.describe('@layout-editor 텍스트 propControl 동적 다국어 (부록7 7- expect(await noBodyHScroll()).toBe(true); // 🌐 펼침(ko/en/ja 폼) — 펼침 폼도 본문을 넘기지 않아야 한다. + // 펼침부 testid 는 `g7le-translation-*` 공용 위젯으로 통합됐다. await page.getByTestId('g7le-prop-i18n-inputPlaceholder-toggle').click(); - await expect(page.getByTestId('g7le-prop-i18n-inputPlaceholder-expand')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId('g7le-translation-field')).toBeVisible({ timeout: 10_000 }); expect(await noBodyHScroll()).toBe(true); // 펼침 폼 ko 입력칸에 긴 텍스트 — 펼침 상태에서도 넘침 없음. await page - .getByTestId('g7le-prop-i18n-inputPlaceholder-expand-input-ko') + .getByTestId('g7le-translation-input-ko') .fill('아주 긴 다국어 라벨 텍스트 가나다라마바사아자차카타파하그그그그그그'); expect(await noBodyHScroll()).toBe(true); }); diff --git a/tests/Playwright/specs/layout-editor/property-editor-new-style-controls.spec.ts b/tests/Playwright/specs/layout-editor/property-editor-new-style-controls.spec.ts index b5bd0332..d2a61187 100644 --- a/tests/Playwright/specs/layout-editor/property-editor-new-style-controls.spec.ts +++ b/tests/Playwright/specs/layout-editor/property-editor-new-style-controls.spec.ts @@ -13,9 +13,15 @@ * @effects box_class_tokens + border_color_token + italic_underline_tokens + text_justify_token + advanced_permission_id_visible */ import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth'; +import { editorPath } from '../../fixtures/layout-editor'; -const CARD = '2.children.5.children.0.children.0.children.1'; // 로그인 카드(Div 컨테이너) -const CARD_H2 = `${CARD}.children.0`; // 카드 안 제목 +/** 로그인 카드(Div 컨테이너) — 본문 루트 기준 상대 경로 */ +const CARD_REL = 'children.5.children.0.children.0.children.1'; + +// 절대 경로의 첫 세그먼트는 베이스 레이아웃 루트 인덱스라 베이스에 컴포넌트가 추가되면 밀린다. +// 리터럴로 두지 않고 openEditorLogin 이 본문 루트 id 로 해석해 채운다 (사용처는 그대로 읽는다). +let CARD = ''; +let CARD_H2 = ''; async function openEditorLogin(page: import('@playwright/test').Page): Promise { const token = issueToken('core.templates.layouts.edit'); @@ -27,6 +33,32 @@ async function openEditorLogin(page: import('@playwright/test').Page): Promise document.querySelectorAll('[data-editor-path]').length > 0, { timeout: 20_000 }, ); + + CARD = await editorPath(page, CARD_REL); + CARD_H2 = `${CARD}.children.0`; +} + +/** + * 노드를 클릭해 선택 오버레이가 뜨는지 확인합니다 (선택 가능 여부 판정). + * + * @param page Playwright page + * @param editorPath 대상 노드의 editor path + * @returns 선택 오버레이(ⓘ 버튼)가 떴으면 true + */ +async function trySelect(page: import('@playwright/test').Page, editorPath: string): Promise { + await page.evaluate((p) => { + const el = document.querySelector(`[data-editor-path="${p}"]`); + if (!el) return; + const r = el.getBoundingClientRect(); + for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) { + el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, clientX: r.left + 8, clientY: r.top + 8, view: window })); + } + }, editorPath); + + return page + .waitForSelector('[data-testid="g7le-overlay-info-button"]', { timeout: 2_500 }) + .then(() => true) + .catch(() => false); } async function openPropsFor(page: import('@playwright/test').Page, editorPath: string): Promise { @@ -146,14 +178,29 @@ test.describe('@layout-editor 신규 스타일 컨트롤', () => { { timeout: 30_000 }, ); - // 대시보드 본문의 카드형 Div(rounded+shadow+bg-white) 선택 - const cardPath = await page.evaluate(() => { - const card = [...document.querySelectorAll('[data-editor-path]')].find( - (e) => e.tagName === 'DIV' && /rounded/.test(e.className) && /shadow/.test(e.className) && /bg-white/.test(e.className), - ); - return card?.getAttribute('data-editor-path') ?? null; - }); - expect(cardPath).toBeTruthy(); + // 대시보드 본문의 컨테이너형 Div 를 고른다. + // + // 예전에는 `rounded+shadow+bg-white` 로 골랐지만 관리자 카드 외형이 한 기준으로 통일되면서 + // 대시보드 Div 에 `shadow`/`bg-white` 가 없어졌다(실측: shadow 0개). 외형 클래스로 지목하면 + // 스타일이 바뀔 때마다 다시 깨지고, `rounded` 만으로 고르면 편집기가 선택을 허용하지 않는 + // 노드(잠금 영역 등)에 걸린다. 그래서 클래스가 아니라 **실제로 선택되는지**로 고른다. + const candidates = await page.evaluate(() => + [...document.querySelectorAll('[data-editor-path]')] + .filter((e) => e.tagName === 'DIV' && e.querySelectorAll('[data-editor-path]').length >= 2) + .map((e) => e.getAttribute('data-editor-path') ?? '') + .filter(Boolean), + ); + expect(candidates.length).toBeGreaterThan(0); + + let cardPath: string | null = null; + for (const candidate of candidates.slice(0, 15)) { + const selected = await trySelect(page, candidate); + if (selected) { + cardPath = candidate; + break; + } + } + expect(cardPath, '대시보드에서 선택 가능한 컨테이너 Div 를 찾지 못했다').toBeTruthy(); await openPropsFor(page, cardPath!); // 6 박스 컨트롤 전부 렌더 diff --git a/tests/Playwright/specs/layout-editor/property-editor-s7-followup.spec.ts b/tests/Playwright/specs/layout-editor/property-editor-s7-followup.spec.ts index 088e29bb..d67ef3a1 100644 --- a/tests/Playwright/specs/layout-editor/property-editor-s7-followup.spec.ts +++ b/tests/Playwright/specs/layout-editor/property-editor-s7-followup.spec.ts @@ -11,8 +11,14 @@ * @effects flex_container_controls_and_disable + pt_pl_coexist + left_right_enabled_in_flex + table_tr_td_rendered */ import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth'; +import { editorPath as resolveEditorPath } from '../../fixtures/layout-editor'; -const CARD = '2.children.5.children.0.children.0.children.1'; // 로그인 카드(Div, 비-flex) +/** 로그인 카드(Div, 비-flex) — 본문 루트 기준 상대 경로 */ +const CARD_REL = 'children.5.children.0.children.0.children.1'; + +// 절대 경로의 첫 세그먼트는 베이스 레이아웃 루트 인덱스라 베이스에 컴포넌트가 추가되면 밀린다. +// 리터럴로 두지 않고 openEditorLogin 이 본문 루트 id 로 해석해 채운다 (사용처는 그대로 읽는다). +let CARD = ''; async function openEditorLogin(page: import('@playwright/test').Page): Promise { const token = issueToken('core.templates.layouts.edit'); @@ -24,6 +30,8 @@ async function openEditorLogin(page: import('@playwright/test').Page): Promise document.querySelectorAll('[data-editor-path]').length > 0, { timeout: 20_000 }, ); + + CARD = await resolveEditorPath(page, CARD_REL); } /** 지정 path 노드를 선택하고 ⓘ → 속성 설정으로 속성 편집 모달을 연다. */ @@ -200,8 +208,10 @@ test.describe('@layout-editor S7 후속 회귀', () => { for (const t of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) el.dispatchEvent(new MouseEvent(t, { bubbles: true, cancelable: true, clientX: r.left + 10, clientY: r.top + 10, view: window })); }, CARD); + // 라벨은 방향 표시용 `↑`(aria-hidden 장식) 를 앞에 두므로 타입 식별자는 뒤쪽으로 단언한다. + // 배치 판정은 장식이 아니라 data-placement 가 담당한다. const divLabel = page.getByTestId('g7le-overlay-type-label'); - await expect(divLabel).toHaveText('Div'); + await expect(divLabel).toHaveText(/Div$/); expect(await divLabel.getAttribute('data-placement')).toBe('inside'); // H2 헤딩(얇은 박스) 선택 → "H2" 라벨, outside 배치(콘텐츠 비가림) @@ -212,7 +222,7 @@ test.describe('@layout-editor S7 후속 회귀', () => { el.dispatchEvent(new MouseEvent(t, { bubbles: true, cancelable: true, clientX: r.left + 6, clientY: r.top + 6, view: window })); }, `${CARD}.children.0`); const h2Label = page.getByTestId('g7le-overlay-type-label'); - await expect(h2Label).toHaveText('H2'); + await expect(h2Label).toHaveText(/H2$/); expect(await h2Label.getAttribute('data-placement')).toBe('outside'); }); }); diff --git a/tests/Playwright/specs/layout-editor/property-editor-scope.spec.ts b/tests/Playwright/specs/layout-editor/property-editor-scope.spec.ts index b0ccd02b..13727f37 100644 --- a/tests/Playwright/specs/layout-editor/property-editor-scope.spec.ts +++ b/tests/Playwright/specs/layout-editor/property-editor-scope.spec.ts @@ -19,8 +19,14 @@ */ import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth'; import type { Page } from '@playwright/test'; +import { editorPath as resolveEditorPath } from '../../fixtures/layout-editor'; -const CARD = '2.children.5.children.0.children.0.children.1'; // 로그인 카드(Div) +/** 로그인 카드(Div) — 본문 루트 기준 상대 경로 */ +const CARD_REL = 'children.5.children.0.children.0.children.1'; + +// 절대 경로의 첫 세그먼트는 베이스 레이아웃 루트 인덱스라 베이스에 컴포넌트가 추가되면 밀린다. +// 리터럴로 두지 않고 openEditorLogin 이 본문 루트 id 로 해석해 채운다 (사용처는 그대로 읽는다). +let CARD = ''; async function openEditorLogin(page: Page): Promise { const token = issueToken('core.templates.layouts.edit'); @@ -32,6 +38,8 @@ async function openEditorLogin(page: Page): Promise { () => document.querySelectorAll('[data-editor-path]').length > 0, { timeout: 20_000 }, ); + + CARD = await resolveEditorPath(page, CARD_REL); } async function openPropsFor(page: Page, editorPath: string): Promise { diff --git a/tests/Playwright/specs/layout-editor/sample-data-canvas-render.spec.ts b/tests/Playwright/specs/layout-editor/sample-data-canvas-render.spec.ts index 6ba63afb..75a0508d 100644 --- a/tests/Playwright/specs/layout-editor/sample-data-canvas-render.spec.ts +++ b/tests/Playwright/specs/layout-editor/sample-data-canvas-render.spec.ts @@ -21,7 +21,9 @@ test.describe('@layout-editor 캔버스 샘플 데이터 렌더', () => { const tokenAuth = issueToken('core.templates.layouts.edit'); await authenticatePage(page, tokenAuth); - await page.goto('/admin/layout-editor/sirsoft-basic?route=%2Fshop%2Fproducts%2F%3Aid'); + // 상품 상세 라우트의 파라미터명은 `:product_code` 다 — `:id` 라우트는 존재하지 않아 + // 레이아웃이 해석되지 않고 캔버스가 끝내 뜨지 않는다. + await page.goto('/admin/layout-editor/sirsoft-basic?route=%2Fshop%2Fproducts%2F%3Aproduct_code'); await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); const canvas = page.getByTestId('g7le-preview-canvas'); diff --git a/tests/Playwright/specs/layout-editor/sample-gate-body-render.spec.ts b/tests/Playwright/specs/layout-editor/sample-gate-body-render.spec.ts index 2e64326d..d6920bc7 100644 --- a/tests/Playwright/specs/layout-editor/sample-gate-body-render.spec.ts +++ b/tests/Playwright/specs/layout-editor/sample-gate-body-render.spec.ts @@ -31,7 +31,11 @@ test.describe('@layout-editor 게이트 본체 캔버스 노출', () => { const token = issueToken('core.templates.layouts.edit'); await authenticatePage(page, token); - await page.goto('/admin/layout-editor/sirsoft-ecommerce'); + // URL 의 마지막 세그먼트는 **템플릿 식별자** 자리다(편집기 진입점 `_tab_admin.json` 의 + // `/admin/layout-editor/{{row.identifier}}` — 템플릿 목록 행). `sirsoft-ecommerce` 는 모듈이라 + // 템플릿으로 존재하지 않아 라우트 트리가 비고, 아래 `data-route-path` 가 영원히 나타나지 않았다. + // 모듈 admin 라우트는 admin 템플릿 트리에 병합되므로 admin 템플릿으로 진입한다. + await page.goto('/admin/layout-editor/sirsoft-admin_basic'); await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); // 배송정책 수정 라우트 노드 선택 (admin 프리픽스 → 트리 클릭) await page.waitForSelector('[data-route-path="*/admin/ecommerce/shipping-policies/:id/edit"]', { @@ -70,7 +74,6 @@ test.describe('@layout-editor 게이트 본체 캔버스 노출', () => { await expect(select).toBeVisible({ timeout: 15_000 }); const frame = page.getByTestId('g7le-preview-frame'); - const baselineLen = (await frame.innerText()).length; // my_comments 서브탭 상태로 전환 → 지연 소스 myComments 샘플이 주입되어 내 댓글 목록 본체가 렌더. await select.selectOption('my_comments'); @@ -80,13 +83,23 @@ test.describe('@layout-editor 게이트 본체 캔버스 노출', () => { { timeout: 5_000 }, ); - // 본체 렌더 신호 — 내 댓글 목록이 채워지면 캔버스 텍스트 길이가 baseline 대비 늘어난다. - // 회귀(지연 소스 미주입)였다면 게이트 본체 공백으로 길이 변화 없음. + // 본체 렌더 판정은 **내용**으로 한다. + // + // 이전에는 "캔버스 텍스트 길이가 baseline 대비 늘어난다" 를 신호로 썼는데 그 전제가 틀렸다 — + // 실측(2026-07-30): 본체가 정상 렌더돼도 길이는 881 → 669 로 **줄어든다**. 내 댓글 항목이 + // 내 글 항목보다 짧아 서브탭을 바꾸면 총 길이가 감소한다. 길이 증감은 본체 렌더 여부와 + // 무관한 지표이므로, 샘플 myComments 데이터의 고유 문구가 캔버스에 나타나는지로 판정한다 + // (회귀 = 지연 소스 미주입이면 이 문구들이 하나도 렌더되지 않는다). await expect - .poll(async () => (await frame.innerText()).length, { - message: 'my_comments 서브탭 본체(내 댓글 목록)가 렌더되어 캔버스 텍스트가 늘어나야 함', + .poll(async () => (await frame.innerText()).includes('NoInfer'), { + message: 'my_comments 서브탭 본체(내 댓글 목록)에 지연 소스 샘플 항목이 렌더되어야 함', timeout: 20_000, }) - .toBeGreaterThan(baselineLen); + .toBe(true); + + // 단일 문구 우연 일치를 배제 — 샘플 항목 다수가 함께 렌더되어야 목록 본체가 살아 있는 것이다. + const rendered = await frame.innerText(); + expect(rendered).toContain('모니터 암'); + expect(rendered).toContain('재택근무 환경 셋업'); }); }); diff --git a/tests/Playwright/specs/layout-editor/scroll-and-extension-lock-followup.spec.ts b/tests/Playwright/specs/layout-editor/scroll-and-extension-lock-followup.spec.ts index 28ae3501..bba38618 100644 --- a/tests/Playwright/specs/layout-editor/scroll-and-extension-lock-followup.spec.ts +++ b/tests/Playwright/specs/layout-editor/scroll-and-extension-lock-followup.spec.ts @@ -26,7 +26,12 @@ async function openBasicEditor(page: Page): Promise { await authenticatePage(page, token); await page.goto('/admin/layout-editor/sirsoft-basic?route=%2Fboards'); await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); - await page.waitForSelector('[data-testid="g7le-preview-frame"] [data-editor-path]', { timeout: 30_000 }); + // state 기본값은 'visible' 인데 프레임 하위 첫 노드는 빈 앵커(header_currency_inject_anchor)나 + // `hidden` 클래스 요소일 수 있어 끝내 visible 이 되지 않는다 — 렌더 완료 판정은 존재로 한다. + await page.waitForSelector('[data-testid="g7le-preview-frame"] [data-editor-path]', { + state: 'attached', + timeout: 30_000, + }); } /** admin 풀스크린 레이아웃(h-screen 루트 + 확장 주입 마케팅 조각) 편집기 진입. */ @@ -35,7 +40,12 @@ async function openAdminUserForm(page: Page): Promise { await authenticatePage(page, token); await page.goto('/admin/layout-editor/sirsoft-admin_basic?route=*%2Fadmin%2Fusers%2Fcreate'); await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); - await page.waitForSelector('[data-testid="g7le-preview-frame"] [data-editor-path]', { timeout: 30_000 }); + // state 기본값은 'visible' 인데 프레임 하위 첫 노드는 빈 앵커(header_currency_inject_anchor)나 + // `hidden` 클래스 요소일 수 있어 끝내 visible 이 되지 않는다 — 렌더 완료 판정은 존재로 한다. + await page.waitForSelector('[data-testid="g7le-preview-frame"] [data-editor-path]', { + state: 'attached', + timeout: 30_000, + }); } test.describe('@layout-editor 스크롤 + 확장 잠금 후속', () => { @@ -46,11 +56,14 @@ test.describe('@layout-editor 스크롤 + 확장 잠금 후속', () => { // 그 내부 깊은 자식을 클릭해도 통짜(진입점) 선택되어야 한다. const result = await page.evaluate(async () => { const frame = document.querySelector('[data-testid="g7le-preview-frame"]')!; - // 확장 조각 진입점은 "Marketing & Consent" 라벨을 포함한다(샘플 마케팅 플러그인 주입 섹션). + // 확장 조각 진입점은 마케팅 동의 섹션 라벨을 포함한다(샘플 마케팅 플러그인 주입 섹션). + // 라벨은 다국어(`admin.users.form.sections.marketing_consent`)라 로케일에 묶지 않는다. // 텍스트 기반으로 영역을 찾은 뒤 그 안 가장 깊은 자식(개별 텍스트 노드)을 클릭한다 — // 깊은 자식이 통짜(진입점) 단위로 정규화되어 선택되는지 검증. const candidates = Array.from(frame.querySelectorAll('[data-editor-path]')).filter( - (e) => /Marketing & Consent/.test(e.textContent || '') && (e.textContent || '').length < 30, + (e) => + /마케팅 및 동의|Marketing & Consent/.test(e.textContent || '') && + (e.textContent || '').length < 30, ) as HTMLElement[]; const labelNode = candidates[candidates.length - 1]; if (!labelNode) return { found: false }; diff --git a/tests/Playwright/specs/layout-editor/separate-edit-modes.spec.ts b/tests/Playwright/specs/layout-editor/separate-edit-modes.spec.ts index 21f0283b..0501a956 100644 --- a/tests/Playwright/specs/layout-editor/separate-edit-modes.spec.ts +++ b/tests/Playwright/specs/layout-editor/separate-edit-modes.spec.ts @@ -21,10 +21,35 @@ async function enterEditor(page: import('@playwright/test').Page): Promise await page.waitForSelector('[data-testid="g7le-route-tree-item"]', { timeout: 30_000 }); } -/** 트리에서 텍스트로 항목을 찾아 클릭 */ -async function clickTreeItem(page: import('@playwright/test').Page, text: RegExp): Promise { - const item = page.locator('[data-testid="g7le-route-tree-item"]', { hasText: text }).first(); - await item.click(); +/** + * 트리에서 항목을 찾아 클릭합니다. + * + * 화면 텍스트(라벨)와 식별자(`data-route-path`) 중 **어느 쪽으로든** 찾는다. 모달·확장 항목은 + * 라벨이 다국어 제목("활동 로그 삭제 확인")이고 식별자(`delete_confirm_modal`)는 + * `data-route-path` 에만 있어 텍스트 매칭만으로는 잡히지 않는다. + * + * @param page Playwright page + * @param pattern 라벨 또는 route-path 에 대해 매칭할 정규식 + */ +async function clickTreeItem(page: import('@playwright/test').Page, pattern: RegExp): Promise { + const path = await page.evaluate( + (src) => { + const re = new RegExp(src); + + return ( + Array.from(document.querySelectorAll('[data-testid="g7le-route-tree-item"]')) + .map((e) => ({ + path: e.getAttribute('data-route-path') ?? '', + text: (e.textContent || '').trim(), + })) + .find((x) => re.test(x.path) || re.test(x.text))?.path ?? null + ); + }, + pattern.source, + ); + + expect(path, `라우트 트리에서 ${pattern} 에 해당하는 항목을 찾지 못했다`).toBeTruthy(); + await page.locator(`[data-route-path="${path}"]`).first().click(); } /** 캔버스의 편집 가능 노드(data-editor-path) 개수 */ diff --git a/tests/Playwright/specs/layout-editor/table-editor.spec.ts b/tests/Playwright/specs/layout-editor/table-editor.spec.ts index 0f8307f5..ffc0b5ec 100644 --- a/tests/Playwright/specs/layout-editor/table-editor.spec.ts +++ b/tests/Playwright/specs/layout-editor/table-editor.spec.ts @@ -15,10 +15,12 @@ * 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 + * @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 { const token = issueToken('core.templates.layouts.edit'); @@ -58,26 +60,81 @@ async function openPropsTab(page: Page): Promise { await page.waitForTimeout(200); } -/** content root(Div) 안에 Table 을 추가하고 그 path 반환. */ -async function addTable(page: Page): Promise { - const containerPath = await page.evaluate(() => { - const els = Array.from(document.querySelectorAll('[data-editor-name="Div"]')); - return els[0]?.getAttribute('data-editor-path') ?? null; - }); - expect(containerPath).toBeTruthy(); - expect(await selectByPath(page, containerPath!)).toBe(true); +/** + * 편집기에서 **실제로 선택되는** Div 컨테이너를 찾아 그 path 를 반환합니다. + * + * 캔버스의 첫 Div 는 잠금 영역이나 래퍼라 선택되지 않는 경우가 있다. 순번으로 지목하지 않고 + * 선택 성공 여부로 고른다. + * + * @param page Playwright page + * @returns 선택에 성공한 Div 의 editor path (없으면 null) + */ +async function pickSelectableDiv(page: Page): Promise { + 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 { + 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(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!); + 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)', () => { @@ -98,7 +155,10 @@ test.describe('@layout-editor table 노드 에디터(빌트인 STRUCT-TREE)', () 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(); @@ -124,9 +184,12 @@ test.describe('@layout-editor table 노드 에디터(빌트인 STRUCT-TREE)', () 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); - const tablePath = await addTable(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(); diff --git a/tests/Playwright/specs/layout-editor/table-inplace-overlay.spec.ts b/tests/Playwright/specs/layout-editor/table-inplace-overlay.spec.ts index e5e99040..9e1cc519 100644 --- a/tests/Playwright/specs/layout-editor/table-inplace-overlay.spec.ts +++ b/tests/Playwright/specs/layout-editor/table-inplace-overlay.spec.ts @@ -16,10 +16,12 @@ * tableGridModel.test.ts)가 잠그고, 본 E2E 는 인플레이스 라이브 반영·저장을 브라우저로 확인. * * @scenario table_canvas_inplace_overlay + inplace_add_row_col + inplace_merge + live_persist - * @effects editorcanvasoverlay_dispatches_canvasoverlay_by_kind_with_measured_cellboxes, table_inplace_overlay_registered_via_registercoreeditors_kind_agnostic, inplace_gutter_add_row_col_shares_tablegridmutations_with_property_panel, inplace_shift_select_merge_sets_origin_span_removes_absorbed, live_inplace_cell_edit_save_persists_to_user_page + * @effects editorcanvasoverlay_dispatches_canvasoverlay_by_kind_with_measured_cellboxes, table_inplace_overlay_registered_via_registercoreeditors_kind_agnostic, inplace_gutter_add_row_col_shares_tablegridmutations_with_property_panel, inplace_shift_select_merge_sets_origin_span_removes_absorbed, live_inplace_cell_edit_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 { const token = issueToken('core.templates.layouts.edit'); @@ -52,17 +54,60 @@ async function selectByPath(page: Page, path: string): Promise { .catch(() => false); } +/** + * 편집기에서 **실제로 선택되는** Div 컨테이너를 찾아 그 path 를 반환합니다. + * + * 캔버스의 첫 Div 는 잠금 영역이나 래퍼라 선택되지 않는 경우가 있다. 순번·클래스로 지목하지 않고 + * 선택 성공 여부로 고른다. + * + * @param page Playwright page + * @returns 선택에 성공한 Div 의 editor path (없으면 null) + */ +async function pickSelectableDiv(page: Page): Promise { + 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); + + const selected = await page + .waitForSelector('[data-testid="g7le-overlay-selected"]', { timeout: 1_200 }) + .then(() => true) + .catch(() => false); + if (selected) return candidate; + } + + return null; +} + /** 컨테이너 Div 를 선택하고 Table 을 추가한 뒤, **신규 Table path 를 diff 로 식별**해 반환. * 팔레트 삽입 위치는 선택 컨테이너에 따라 결정되므로 children 인덱스를 계산하지 않고 * 추가 전후 Table data-editor-path 집합 차이로 새 노드를 찾는다(라이브 실측 — 삽입 path 가 - * 컨테이너 children 이 아닐 수 있음). */ -async function addTable(page: Page): Promise { - const containerPath = await page.evaluate(() => { - const els = Array.from(document.querySelectorAll('[data-editor-name="Div"]')); - return els[0]?.getAttribute('data-editor-path') ?? null; - }); - expect(containerPath).toBeTruthy(); - expect(await selectByPath(page, containerPath!)).toBe(true); + * 컨테이너 children 이 아닐 수 있음). + * + * @param page Playwright page + * @param sandbox true 면 시드 화면의 전용 컨테이너(고정 id)를 대상으로 한다 (저장 spec 전용). + */ +async function addTable(page: Page, sandbox = false): Promise { + // 첫 번째 Div 를 무조건 컨테이너로 삼으면 그 노드가 편집기에서 선택 불가일 때 바로 실패한다. + // 순번이 아니라 **실제로 선택되는** Div 를 찾는다. 시드 화면은 컨테이너 id 가 고정이라 직접 지목. + const containerPath = sandbox + ? await editorPath(page, '', SANDBOX_ROOT_ID) + : await pickSelectableDiv(page); + expect(containerPath, '선택 가능한 Div 컨테이너를 찾지 못했다').toBeTruthy(); const before = await page.evaluate(() => Array.from(document.querySelectorAll('[data-editor-name="Table"]')).map((e) => e.getAttribute('data-editor-path')), ); @@ -112,7 +157,10 @@ test.describe('@layout-editor table 캔버스 인플레이스 오버레이(빌 await gotoEditor(page); const tablePath = await addTable(page); expect(await selectByPath(page, tablePath)).toBe(true); - await page.waitForTimeout(300); + // 셀 픽 영역과 드래그 핸들이 마운트될 때까지 기다린다. 고정 sleep 만 두면 마운트 전에 + // pointerdown 을 쏴 `cellArea`/`handle` 이 null 로 떨어지고 조용히 false 가 된다(간헐 실패). + await page.waitForSelector('[data-testid^="g7le-inplace-cell-"]', { timeout: 10_000 }); + await page.waitForSelector(`[data-dnd-handle-path="${tablePath}"]`, { timeout: 10_000 }); // 2단계 모델 — 셀 픽 영역이 표를 덮지만 onPointerDown 을 하위 드래그 핸들로 forward 한다. // 셀 영역 중심에서 pointerdown 발사 → 표 드래그 핸들(data-dnd-handle-path)이 pointerdown 수신. const handleGotPointerDown = await page.evaluate((tp) => { @@ -162,9 +210,17 @@ test.describe('@layout-editor table 캔버스 인플레이스 오버레이(빌 expect(hasColspan2).toBe(true); }); + // 저장(PUT)하는 테스트는 편집 결과가 그대로 영속되므로 제품 화면(home)이 아니라 E2E 전용 + // 시드 화면(e2e_sandbox)을 대상으로 한다. + // + // 배경: 이 테스트가 home 에 저장하던 동안 빈 표가 누적됐다(실측 7개, 20,321 → 33,696 bytes). + // spec 안에 "추가한 표 삭제 → 재저장" 원복을 넣어도 소용이 없었다 — 원복 후에도 레이아웃이 + // 오염 시점과 정확히 같은 크기로 되돌아왔다(편집기가 들고 있던 문서를 통째로 다시 저장). + // 그래서 원복에 기대지 않고 저장 대상 자체를 시드 화면으로 분리했다. 시드 화면은 globalSetup 이 + // 매 실행 fixture 원본으로 덮어쓰므로 회차 간 누적이 성립하지 않는다. test('인플레이스 편집 후 저장 → PUT 200', async ({ page }) => { - await gotoEditor(page); - const tablePath = await addTable(page); + await gotoEditor(page, sandboxRouteParam()); + const tablePath = await addTable(page, true); expect(await selectByPath(page, tablePath)).toBe(true); await page.waitForTimeout(300); await page.getByTestId('g7le-inplace-add-row-bottom').click(); @@ -177,7 +233,6 @@ test.describe('@layout-editor table 캔버스 인플레이스 오버레이(빌 { timeout: 15_000 }, ); await page.getByRole('button', { name: /save|저장/i }).first().click(); - const saveRes = await savePromise; - expect(saveRes.status()).toBe(200); + expect((await savePromise).status()).toBe(200); }); }); diff --git a/tests/Playwright/specs/layout-editor/tabnav-inplace.spec.ts b/tests/Playwright/specs/layout-editor/tabnav-inplace.spec.ts index 8f7520fd..3497b1bc 100644 --- a/tests/Playwright/specs/layout-editor/tabnav-inplace.spec.ts +++ b/tests/Playwright/specs/layout-editor/tabnav-inplace.spec.ts @@ -14,7 +14,9 @@ import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth'; import type { Page } from '@playwright/test'; -async function gotoAdminEditor(page: Page, route = '%2Fadmin'): Promise { +// 관리자 템플릿의 라우트는 모두 `*/admin/...` 프리픽스를 갖는다 — '/admin' 은 존재하지 않고 +// `*/admin` 은 `redirect` 전용(레이아웃 없음)이라, 둘 다 캔버스(g7le-preview-frame)가 뜨지 않는다. +async function gotoAdminEditor(page: Page, route = '*%2Fadmin%2Fdashboard'): Promise { const token = issueToken('core.templates.layouts.edit'); await authenticatePage(page, token); await page.goto(`/admin/layout-editor/sirsoft-admin_basic?route=${route}`); diff --git a/tests/Playwright/specs/production-sourcemap-exposure.spec.ts b/tests/Playwright/specs/production-sourcemap-exposure.spec.ts index 3b1746a6..208efa9b 100644 --- a/tests/Playwright/specs/production-sourcemap-exposure.spec.ts +++ b/tests/Playwright/specs/production-sourcemap-exposure.spec.ts @@ -18,10 +18,29 @@ * 발생한다. 이 spec 은 그 회귀(주석/파일 불일치)도 함께 잡는다. */ import { test, expect } from '@playwright/test'; +import { readFileSync, existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; /** 소스맵 요청으로 간주할 URL 패턴 */ const MAP_REQUEST = /\.map(\?|$)/; +/** + * 대상 인스턴스의 `APP_ENV` 를 읽습니다 (코어 `.env`). + * + * playwright.config.ts 의 base URL 해석과 같은 방식이다 — 전용 엔드포인트에 의존하지 않는다. + * + * @returns APP_ENV 값 (판독 실패 시 null) + */ +function appEnv(): string | null { + const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + const envPath = resolve(root, '.env'); + if (!existsSync(envPath)) return null; + const match = readFileSync(envPath, { encoding: 'utf-8' }).match(/^APP_ENV=(.*)$/m); + + return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; +} + test.describe('프로덕션 소스맵 노출 차단', () => { test('@smoke 사용자 페이지 로드 시 소스맵을 요청하지 않는다', async ({ page }) => { const mapRequests: string[] = []; @@ -94,7 +113,16 @@ test.describe('프로덕션 소스맵 노출 차단', () => { expect(parseErrors, `번들 실행 관련 콘솔 에러: ${parseErrors.join(' | ')}`).toHaveLength(0); }); + // 이 테스트는 **프로덕션(비-local) 전제**다. + // `AllowedPluginFileType` / `AllowedModuleFileType` 은 `app()->environment('local')` 일 때만 + // 허용 확장자에 `map` 을 덧붙인다 (로컬 dev 빌드 산출물 디버깅용, 의도된 분기). 따라서 로컬에서는 + // 200 이 정상이며, 여기서 실패하면 결함이 아니라 환경 불일치다 — 그 환경에서는 건너뛴다. test('@smoke 확장 소스맵 에셋은 직접 요청해도 서빙되지 않는다', async ({ request }) => { + test.skip( + appEnv() === 'local', + 'APP_ENV=local 은 소스맵 서빙을 의도적으로 허용한다 (프로덕션 전제 테스트)', + ); + // 디스크에 맵이 없더라도, 허용 확장자에서 제외됐으므로 200 이 나오면 안 된다. const targets = [ '/api/modules/assets/sirsoft-ecommerce/dist/js/module.iife.js.map', diff --git a/tests/Playwright/specs/smoke/identity-policy-scope-no-language-pack-leak.spec.ts b/tests/Playwright/specs/smoke/identity-policy-scope-no-language-pack-leak.spec.ts index 3bd84890..bbeae46d 100644 --- a/tests/Playwright/specs/smoke/identity-policy-scope-no-language-pack-leak.spec.ts +++ b/tests/Playwright/specs/smoke/identity-policy-scope-no-language-pack-leak.spec.ts @@ -157,7 +157,8 @@ test('@smoke 본인인증 탭 → 언어팩 탭 "클릭 전환" 시 언어팩 (r) => r.url().includes('/api/admin/language-packs') && /[?&]exclude_protected/.test(r.url()), { timeout: 20_000 }, ); - await page.getByRole('button', { name: '언어팩', exact: true }).click(); + // 관리자 탭은 WAI-ARIA 규약대로 `role="tab"` 으로 노출된다 (일반 button 이 아니다). + await page.getByRole('tab', { name: '언어팩', exact: true }).click(); await langPackResp; // 클릭 전환 시 언어팩 데이터소스 fetch 가 발생하지 않으면 여기서 timeout expect(page.url()).toMatch(/[?&]tab=language_packs/); @@ -178,7 +179,8 @@ test('@smoke 언어팩 탭 → 본인인증 탭 "클릭 전환" 시 정책 데 (r) => r.url().includes('/api/admin/identity/policies') && /[?&]per_page=10\b/.test(r.url()), { timeout: 20_000 }, ); - await page.getByRole('button', { name: '본인인증', exact: true }).click(); + // 관리자 탭은 WAI-ARIA 규약대로 `role="tab"` 으로 노출된다 (일반 button 이 아니다). + await page.getByRole('tab', { name: '본인인증', exact: true }).click(); await policyResp; // 클릭 전환 시 정책 데이터소스 fetch 가 발생하지 않으면 여기서 timeout expect(page.url()).toMatch(/[?&]tab=identity/); diff --git a/tests/Playwright/specs/smoke/localinit-multi-progressive-datasource.spec.ts b/tests/Playwright/specs/smoke/localinit-multi-progressive-datasource.spec.ts index 682f86d5..d46d3bb0 100644 --- a/tests/Playwright/specs/smoke/localinit-multi-progressive-datasource.spec.ts +++ b/tests/Playwright/specs/smoke/localinit-multi-progressive-datasource.spec.ts @@ -45,19 +45,27 @@ async function throttleCpu(page: import('@playwright/test').Page, rate: number): await cdp.send('Emulation.setCPUThrottlingRate', { rate }); } -/** SPA 네비게이션으로 배송설정 탭에 진입한다 (직접 URL 접근으로는 재현되지 않는다). */ -async function navigateToShippingTab(page: import('@playwright/test').Page): Promise { +/** + * SPA 네비게이션으로 이커머스 환경설정 탭에 진입한다 (직접 URL 접근으로는 재현되지 않는다). + * + * @param page Playwright page + * @param tab 진입할 탭 (데이터소스 `if` 게이트가 탭으로 걸리므로 등록 소스 구성이 탭마다 다르다) + */ +async function navigateToSettingsTab( + page: import('@playwright/test').Page, + tab: 'shipping' | 'notification_definitions', +): Promise { await page.evaluate(() => { (window as any).G7Core.dispatch({ handler: 'navigate', params: { path: '/admin/dashboard' } }); }); await page.waitForTimeout(2000); - await page.evaluate(() => { + await page.evaluate((t) => { (window as any).G7Core.dispatch({ handler: 'navigate', - params: { path: '/admin/ecommerce/settings', query: { tab: 'shipping' } }, + params: { path: '/admin/ecommerce/settings', query: { tab: t } }, }); - }); + }, tab); } test('@smoke initLocal progressive 소스가 둘 이상이어도 먼저 도착한 초기값이 유실되지 않는다', async ({ page }) => { @@ -68,7 +76,7 @@ test('@smoke initLocal progressive 소스가 둘 이상이어도 먼저 도착 // commit 을 지연시켜 두 progressive 응답이 같은 commit 사이에 도착하도록 만든다. await throttleCpu(page, 6); - await navigateToShippingTab(page); + await navigateToSettingsTab(page, 'shipping'); // settings 소스의 initLocal("form") 이 _local 에 반영되어야 한다. await expect @@ -91,15 +99,34 @@ test('@smoke initLocal progressive 소스가 둘 이상이어도 먼저 도착 // 사용자 화면: 국가 표가 렌더되고, 빈 상태 문구는 보이지 않아야 한다. // (상태 반영과 DOM 커밋 사이에 지연이 있으므로 표 렌더를 먼저 기다린다) await expect.poll(() => page.locator('tbody tr').count(), { timeout: 15_000 }).toBeGreaterThan(0); - await expect(page.getByText('No shipping countries registered.')).toBeHidden(); + // 문구 단언은 로케일에 묶지 않는다 (config 은 ko-KR 을 고정하지만 단언 자체는 양쪽 허용). + await expect( + page.getByText(/등록된 배송국가가 없습니다\.|No shipping countries registered\./), + ).toBeHidden(); }); +// 대상 탭 정정 (2026-07-30 실측): +// 이 테스트는 배송(shipping) 탭에서 `ecommerceNotificationDefinitions` 를 refetch 했는데 요청이 +// 전혀 발생하지 않았다. 원인은 제품 결함이 아니라 **탭 선택 오류**다 — 그 소스의 `if` 게이트는 +// `tab ∈ {notification_definitions, mileage}` 이라 배송 탭에서는 애초에 등록되지 않는다. +// 등록되지 않은 소스의 refetch 가 무연산인 것은 정상 동작이다. +// +// 계측 근거 (탭별 등록 소스 실측): +// - tab=shipping → [settings, carriers] (notification 요청 0 → refetch 후 0) +// - tab=notification_definitions → [settings, ecommerceNotificationDefinitions, availableChannels] +// (notification 요청 1 → refetch 후 2) +// - 대조군 carriers 는 shipping 탭에서 1 → refetch 후 2 (핸들러 자체는 정상) +// +// 본 규칙("소비가 끝난 payload 가 이후 refetch 로 재적용되지 않는가")은 initLocal 을 가진 소스가 +// 둘 이상 등록된 탭에서만 성립한다. `settings`(initLocal:"form") 는 게이트가 없어 전 탭에 등록되고, +// 짝이 되는 `ecommerceNotificationDefinitions`(initLocal 맵) 는 알림정의 탭에만 있으므로 그 탭에서 측정한다. +// `form.shipping.*` 값은 `settings` 응답에 담겨 탭과 무관하게 존재한다(실측: available_countries 10건). test('@smoke 폼 편집 후 다른 데이터소스를 refetch 해도 편집값이 되돌아가지 않는다', async ({ page }) => { await authenticatePage(page, settingsToken()); await page.goto('/admin/dashboard'); await page.waitForFunction(() => !!(window as any).G7Core?.dispatch); - await navigateToShippingTab(page); + await navigateToSettingsTab(page, 'notification_definitions'); await expect .poll( () => diff --git a/tests/Playwright/specs/smoke/network-resilience.spec.ts b/tests/Playwright/specs/smoke/network-resilience.spec.ts index 93ea1575..870c4607 100644 --- a/tests/Playwright/specs/smoke/network-resilience.spec.ts +++ b/tests/Playwright/specs/smoke/network-resilience.spec.ts @@ -182,10 +182,20 @@ test.describe('네트워크 복원력 — 번들이 끝내 부재할 때 (#463)' */ test('@smoke 템플릿 컴포넌트 번들 상시 부재 → 백지가 아니라 폴백 안내 + 새로고침 버튼', async ({ page }) => { let attempts = 0; - await page.route('**/api/templates/assets/**/components.iife.js*', (route) => { - attempts += 1; - return route.abort('failed'); - }); + // 자산 URL 은 **이중 모드**다 — 확장자 형태(`.../js/components.iife.js`)가 실패하면 + // 로더가 확장자 없는 형태(`...?file=js%2Fcomponents.iife.js`)로 재시도한다. 확장자 형태만 + // 막으면 두 번째 형태로 번들이 정상 로드돼(실측 확인) "상시 부재" 를 재현하지 못한다. + await page.route( + (url) => + /\/api\/templates\/assets\//.test(url.pathname) && + (/components\.iife\.js/.test(url.pathname) || + /components\.iife\.js/.test(decodeURIComponent(url.search))), + (route) => { + attempts += 1; + + return route.abort('failed'); + }, + ); await page.goto('/'); await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); diff --git a/tests/Unit/Repositories/LayoutRepositoryDeleteByNameTest.php b/tests/Unit/Repositories/LayoutRepositoryDeleteByNameTest.php new file mode 100644 index 00000000..a5d2bdaf --- /dev/null +++ b/tests/Unit/Repositories/LayoutRepositoryDeleteByNameTest.php @@ -0,0 +1,84 @@ +repository = app(LayoutRepository::class); + } + + /** + * @effects delete_by_name_force_deletes_matching_layout + */ + #[Test] + public function delete_by_name_force_deletes_matching_layout(): void + { + $template = Template::factory()->create(); + TemplateLayout::factory()->create([ + 'template_id' => $template->id, + 'name' => 'e2e_sandbox', + ]); + + $this->assertTrue($this->repository->deleteByName($template->id, 'e2e_sandbox')); + + // soft delete 잔여 행이 남으면 같은 이름 재등록이 유니크 제약과 충돌한다. + $this->assertFalse( + TemplateLayout::withTrashed() + ->where('template_id', $template->id) + ->where('name', 'e2e_sandbox') + ->exists() + ); + } + + /** + * @effects delete_by_name_returns_false_when_absent + */ + #[Test] + public function delete_by_name_returns_false_when_absent(): void + { + $template = Template::factory()->create(); + + $this->assertFalse($this->repository->deleteByName($template->id, 'no_such_layout')); + } + + /** + * @effects delete_by_name_does_not_touch_same_name_in_other_template + */ + #[Test] + public function delete_by_name_does_not_touch_same_name_in_other_template(): void + { + $target = Template::factory()->create(); + $other = Template::factory()->create(); + + TemplateLayout::factory()->create(['template_id' => $target->id, 'name' => 'e2e_sandbox']); + TemplateLayout::factory()->create(['template_id' => $other->id, 'name' => 'e2e_sandbox']); + + $this->repository->deleteByName($target->id, 'e2e_sandbox'); + + $this->assertTrue( + TemplateLayout::where('template_id', $other->id)->where('name', 'e2e_sandbox')->exists(), + '다른 템플릿의 동명 레이아웃은 삭제되지 않아야 한다' + ); + } +} diff --git a/tests/scenarios/layout-editor-e2e-seed-layout.yaml b/tests/scenarios/layout-editor-e2e-seed-layout.yaml new file mode 100644 index 00000000..8e18723a --- /dev/null +++ b/tests/scenarios/layout-editor-e2e-seed-layout.yaml @@ -0,0 +1,54 @@ +feature: 레이아웃 편집기 E2E 전용 시드 화면 (playwright:seed-layout) + +description: | + 레이아웃 편집기 E2E 중 저장(PUT)까지 수행하는 spec 을 제품 화면에서 분리하기 위한 + 전용 시드 화면(`e2e_sandbox`) 설치/제거 인프라. + + 배경: 저장 spec 이 `home` / `admin_dashboard` 를 대상으로 하면 편집 결과가 그대로 + 영속돼 실행마다 노드가 누적된다(실측: home 에 빈 표 7개, 20,321 → 33,696 bytes). + spec 안의 "추가 노드 삭제 후 재저장" 원복은 실패했다 — 원복 후에도 레이아웃이 오염 시점과 + 정확히 같은 크기로 되돌아왔다(편집기가 들고 있던 문서를 통째로 재저장). + + 핵심 동작: + - 활성 템플릿 디렉토리에만 설치 (`_bundled` 배포 원본 무변경, 활성 디렉토리는 Git 무시) + - 3종 동시 처리: 레이아웃 파일 + routes.json 라우트(마커 기반 멱등) + DB 행 upsert + - 시드 행만 건드림 — `template:refresh-layout`(전체 재동기화)을 쓰지 않는다. 그 경로는 + 편집기 UI 로 저장한(파일에 없는) 변경을 전부 되돌리기 때문 + - 가드 2중: CLI 한정 + `G7_PLAYWRIGHT_BYPASS=1` 명시 옵트인 + - globalSetup 이 매 실행 fixture 원본으로 덮어쓰므로 회차 간 누적이 성립하지 않음 + + axes 는 커맨드의 상태 전이 축(설치/제거 × 대상 템플릿의 DB 등록 여부)만 둔다. 가드 거부·멱등· + 미정의 템플릿은 상태 전이가 아니라 진입 차단 경로이므로 axes 조합이 아니라 effects 로 잠근다. + +axes: + action: [install, remove] + template_in_db: [yes, no] + +effects: + - seed_layout_command_requires_explicit_optin_env + - seed_layout_install_creates_file_route_and_db_row + - seed_layout_install_is_idempotent_no_duplicate_route + - seed_layout_remove_cleans_file_route_and_db_row + - seed_layout_remove_keeps_other_routes + - seed_layout_remove_restores_routes_json_byte_for_byte + - seed_layout_repeated_install_keeps_pristine_routes_backup + - seed_layout_skips_template_absent_from_db_without_failing + - seed_layout_remove_skips_template_absent_from_db_without_touching_files + - seed_layout_warns_on_template_without_declared_seed_route + - delete_by_name_force_deletes_matching_layout + - delete_by_name_returns_false_when_absent + - delete_by_name_does_not_touch_same_name_in_other_template + - editor_save_specs_target_sandbox_layout_not_product_layout + - editor_save_persists_and_survives_reload_on_sandbox + - admin_editor_save_specs_target_admin_sandbox_layout + +test_files: + - tests/Feature/Console/PlaywrightSeedLayoutCommandTest.php + - tests/Unit/Repositories/LayoutRepositoryDeleteByNameTest.php + - tests/Playwright/specs/layout-editor/table-editor.spec.ts + - tests/Playwright/specs/layout-editor/table-inplace-overlay.spec.ts + - tests/Playwright/specs/layout-editor/children-list-editor.spec.ts + - tests/Playwright/specs/layout-editor/array-editor.spec.ts + - tests/Playwright/specs/layout-editor/prop-i18n-text-field.spec.ts + - tests/Playwright/specs/layout-editor/palette-i18n-and-globals.spec.ts + - tests/Playwright/specs/layout-editor/data-builtins.spec.ts