*/ 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 호출 실수 차단 // 여기의 `env()` 는 config:cache 의 영향을 받지 않는다 — 이 값은 `.env` 파일이 아니라 // 호출자가 그 자리에서 넘기는 프로세스 환경변수이고, config 로 캡처할 대상도 아니다. // (`.env` 유래 값을 런타임 `env()` 로 읽는 것은 금지다 — config 로 캡처해야 한다.) 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 ); } }