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