fix(core): 라우트 캐시 상태에서 DevTools 덤프·브라우저 로그 수집 복구
route:cache 가 걸리면 RouteServiceProvider 가 캐시 로드로 분기해 라우트 파일이 실행되지 않는다. routes/devtools.php 는 전역 함수 5개를 정의하고 핸들러가 호출했고, boost 의 browser-logs 는 프로바이더 boot 등록분이라 setCompiledRoutes 교체로 폐기됐다. 7.0.6 의 RouteCacheHelper 가 12개 라이프사이클 지점에서 캐시를 자동 생성하면서, 확장을 한 번이라도 건드린 사이트에서 항상 재현됐다. 로직을 오토로드되는 App\Support\DevTools\* 로 옮기고 browser-logs URI 를 G7 가 직접 소유해 캐시에 함께 구워지게 했다. 게이트가 참조하던 App\Models\Setting 은 저장소에 없는 유물이라 ConfigRepositoryInterface 로 교체했다. 같은 부류의 재발은 정적 검사(route-file-local-function)로 차단하고, 재현이 in-process 로 불가능하므로 서브프로세스 하네스로 회귀를 고정했다.
This commit is contained in:
@@ -320,6 +320,18 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
|
||||
|
||||
정규식 location 은 프리픽스 location 보다 먼저 매칭되므로, 정적 최적화 블록(`location ~* \.(js|css|json)$`)이 있는 서버에서는 확장자 붙은 동적 응답이 `try_files ... /index.php` 폴백 기회 없이 404 가 된다. 서버측 `AssetUrl` 과 프론트측 `assetUrl.ts` 는 동일 규칙을 공유하므로 한쪽만 바꾸면 그 자산만 404 가 된다. 상세: [routing.md](docs/backend/routing.md) "정적 확장자로 끝나는 동적 엔드포인트", [api/README.md](docs/backend/api/README.md) "자산 URL 이중 모드".
|
||||
|
||||
### 라우트 캐시 안전성
|
||||
|
||||
`route:cache` 가 걸리면 `RouteServiceProvider::boot()` 이 캐시 로드로 분기해 라우트 파일 자체가 실행되지 않는다. 클로저는 직렬화 형태로 복원되므로 문제가 아니다 (`routes/web.php` SPA catch-all 이 증거). 깨지는 것은 오토로드되지 않는 심볼 참조뿐이다.
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|------|------------|
|
||||
| 라우트 파일에 전역 함수 선언 + 핸들러가 호출 | 로직을 클래스(`app/Support/…`)로 옮기고 핸들러는 위임만 |
|
||||
| 파일 스코프 변수를 핸들러가 `use` 없이 참조 | 클래스 상수 또는 `use ($var)` 로 클로저에 캡처 |
|
||||
| 벤더/프로바이더가 `boot()` 에서 조건부 등록하는 라우트에 의존 | 그 URI 를 G7 라우트 파일이 직접 소유 |
|
||||
|
||||
전역 함수 위반은 `Call to undefined function` 500 인데 예외의 `file` 이 `laravel-serializable-closure://` 라 원인 파일이 스택에 드러나지 않는다. 프로바이더 등록분이 사라지는 이유는 별개다 — `Router::setCompiledRoutes()` 가 `booted` 콜백에서 라우트 컬렉션을 통째로 교체하므로 그보다 앞선 등록은 조건 충족 여부와 무관하게 폐기된다(프레임워크 자신의 `BroadcastManager::routes()` 는 `routesAreCached()` 가드를 갖지만 모든 패키지가 그렇지는 않다). 정적 검사가 라우트 파일의 전역 함수 선언을 차단한다. 상세: [routing.md](docs/backend/routing.md) "캐시 안전한 라우트 작성".
|
||||
|
||||
### 목록 컨텍스트 왕복 (list context round-trip)
|
||||
|
||||
페이지네이션 목록 화면과 그에 딸린 상세·형제 상세·작성/수정 폼·확인 모달은 하나의 목록 클러스터다. 이 클러스터 안에서의 이동은 URL 목록 상태(`page`/`search`/`category`/`filters[*]`/정렬/`per_page`)를 손실 없이 보존해야 한다.
|
||||
|
||||
@@ -176,6 +176,13 @@
|
||||
- 모듈·플러그인 업데이트가 실패하거나 도중에 중단된 뒤, 확장은 정상인데 그 관리자 화면만 "페이지를 찾을 수 없습니다"로 나오고 하루가 지나야 돌아오던 문제를 수정했습니다. 업데이트가 진행되는 동안 사이트를 열어 본 사람이 있으면 그 순간의 "이 확장은 사용 중이 아님" 상태가 기억되는데, 실패로 끝나면 그 기억이 정리되지 않았습니다. 이제 실패로 끝나도 즉시 정리되며, 잘못된 상태가 애초에 기억되지 않도록 함께 보완했습니다.
|
||||
- 데이터가 많은 사이트를 업그레이드할 때 기존 데이터를 채워 넣는 작업이 일부 행을 건너뛰던 문제를 수정했습니다. 100건씩 나눠 처리하면서 처리한 행이 대상 목록에서 빠지는 만큼 다음 묶음의 시작 위치가 밀려, 250건이면 100건이 처리되지 않은 채 남았습니다. 레이아웃 원본 대조값 채우기와 `json:convert-unicode` 명령이 대상이며, 이제 건수와 무관하게 전건이 처리됩니다. (#84 @glitter-gim 님께서 제보해주셨습니다.)
|
||||
|
||||
#### 개발자 도구
|
||||
|
||||
- 디버그 모드에서 개발자 도구의 상태 내려받기가 오류를 내며 실패하던 문제를 수정했습니다. 모듈·플러그인을 설치하거나 켜고 끄면 사이트가 라우트 정보를 미리 만들어 두는데, 그 상태에서는 상태 내려받기 기능이 필요한 코드를 찾지 못했습니다. 확장을 한 번이라도 건드린 사이트에서는 항상 실패했습니다.
|
||||
- 같은 조건에서 브라우저 콘솔 로그 수집이 조용히 멈추던 문제를 수정했습니다. 로그를 받는 주소가 사라진 상태였는데 실패가 화면에도 기록에도 남지 않아, 디버그 모드를 켜 두었는데도 `storage/logs/browser.log` 만 비어 있었습니다.
|
||||
- 관리자 환경설정에서만 디버그 모드를 켰을 때(`.env` 는 꺼진 상태) 개발자 도구 요청이 접근 거부 대신 오류로 응답하던 문제를 수정했습니다.
|
||||
- 개발자 도구 응답 문구가 사이트 언어와 무관하게 항상 한국어로만 나오던 것을 사이트 언어를 따르도록 바꿨습니다.
|
||||
|
||||
#### 설정 저장·입력 검증
|
||||
|
||||
- 모듈·플러그인 설정의 숫자 항목이 숫자가 아닌 형태로 보관되어 있어도, 조회 시 설정 기본값과 같은 숫자 형태로 처리합니다. 이전에는 저장된 형태에 따라 기한 계산 같은 후속 처리에서 오류가 발생할 수 있었습니다. 숫자가 아닌 값, 참/거짓 항목, 목록 항목은 그대로 유지됩니다. 설정 안에 다시 묶여 있는 하위 항목까지 같은 규칙이 적용됩니다.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\DevTools;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* 브라우저 콘솔 로그 수집기 (`POST _boost/browser-logs`).
|
||||
*
|
||||
* 왜 G7 가 이 엔드포인트를 소유하는가:
|
||||
* 원래는 `Laravel\Boost\BoostServiceProvider::boot()` 이 등록했다. 그런데 라우트 캐시가
|
||||
* 걸리면 `Router::setCompiledRoutes()` 가 라우트 컬렉션을 **통째로 교체**하고, 이 교체는
|
||||
* `booted` 콜백에서 일어난다. 즉 프로바이더 `boot()` 에서 등록한 라우트는 조건 충족 여부와
|
||||
* 무관하게 전부 폐기된다 (프레임워크 자신의 `BroadcastManager::routes()` 는 `routesAreCached()`
|
||||
* 가드를 갖고 있으나 boost 에는 없다).
|
||||
*
|
||||
* 그 결과 `routes/web.php` 의 SPA catch-all(GET 전용)만 남아 POST 가 405 로 튕기고,
|
||||
* `BrowserLogger` 는 `Route::has('boost.browser-logs')` 가 false 라 폴백 리터럴 URL 을
|
||||
* 스크립트에 굽는다. fetch 실패는 `.catch` 로 삼켜져 **오류 한 줄 없이 로그 수집만 사라진다.**
|
||||
* G7 라우트 파일에서 직접 등록하면 캐시에 함께 구워져 이 경로가 살아남는다.
|
||||
*
|
||||
* 로그 채널 결정:
|
||||
* `logging.channels.browser` 는 `config/logging.php` 에 없고 boost 가 런타임에 주입한다.
|
||||
* 그 주입은 boost 의 `shouldRun()` 뒤에 있어 testing·비-local·`boost.enabled=false` 에서는
|
||||
* 아예 일어나지 않는다. 따라서 채널 존재를 전제하지 않고 없으면 직접 만든다.
|
||||
* 반대로 `config/logging.php` 에 채널을 정적 정의하지는 **않는다** — 정의하면 boost 의
|
||||
* `registerBrowserLogger()` 가 `!== null` 조건에 걸려 조용히 스킵되므로 결합이 오히려 는다.
|
||||
*/
|
||||
class BrowserLogWriter
|
||||
{
|
||||
/**
|
||||
* 브라우저가 전송한 콘솔 로그를 기록합니다.
|
||||
*
|
||||
* @param Request $request 로그 전송 요청
|
||||
* @return JsonResponse 처리 결과
|
||||
*/
|
||||
public static function handle(Request $request): JsonResponse
|
||||
{
|
||||
$logs = $request->input('logs', []);
|
||||
|
||||
// 빈 요청이 browser.log 파일을 만들지 않도록 채널 해석 자체를 미룬다.
|
||||
if (! is_array($logs) || $logs === []) {
|
||||
return response()->json(['status' => 'logged']);
|
||||
}
|
||||
|
||||
$logger = self::channel();
|
||||
|
||||
foreach ($logs as $log) {
|
||||
if (! is_array($log)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$logger->log(
|
||||
self::level($log['type'] ?? 'log'),
|
||||
self::buildMessage($log['data'] ?? []),
|
||||
[
|
||||
'url' => $log['url'] ?? null,
|
||||
'user_agent' => ($log['userAgent'] ?? null) ?: null,
|
||||
'timestamp' => ($log['timestamp'] ?? null) ?: now()->toIso8601String(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'logged']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저 로그 타입을 PSR-3 로그 레벨로 변환합니다.
|
||||
*
|
||||
* boost 원본은 `$log['type']` 이 없으면 `match` 에서 UnhandledMatchError 로 터진다.
|
||||
* 여기서는 호출부에서 기본값을 채워 넘긴다.
|
||||
*
|
||||
* @param mixed $type 브라우저 로그 타입
|
||||
* @return string PSR-3 로그 레벨
|
||||
*/
|
||||
private static function level(mixed $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'warn' => 'warning',
|
||||
'log', 'table' => 'debug',
|
||||
'window_error', 'uncaught_error', 'unhandled_rejection' => 'error',
|
||||
'error', 'info', 'notice', 'critical', 'alert', 'emergency' => $type,
|
||||
default => 'debug',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저 로그 data 배열을 사람이 읽을 수 있는 한 줄 메시지로 만듭니다.
|
||||
*
|
||||
* 중첩 배열(예: `{"message":"...","reason":{"name":"TypeError",...}}`)도 재귀 평탄화한다.
|
||||
*
|
||||
* @param mixed $data 로그 data 페이로드
|
||||
* @return string 조립된 메시지
|
||||
*/
|
||||
private static function buildMessage(mixed $data): string
|
||||
{
|
||||
if (! is_array($data)) {
|
||||
return self::stringify($data);
|
||||
}
|
||||
|
||||
return implode(' ', array_map(
|
||||
static fn (mixed $value): string => is_array($value)
|
||||
? self::buildMessage($value)
|
||||
: self::stringify($value),
|
||||
$data
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 스칼라/객체 값을 문자열로 변환합니다.
|
||||
*
|
||||
* @param mixed $value 변환할 값
|
||||
* @return string 문자열 표현
|
||||
*/
|
||||
private static function stringify(mixed $value): string
|
||||
{
|
||||
return match (true) {
|
||||
is_string($value) => $value,
|
||||
is_numeric($value) => (string) $value,
|
||||
is_bool($value) => $value ? 'true' : 'false',
|
||||
is_null($value) => 'null',
|
||||
default => (string) json_encode($value),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저 로그 채널을 반환합니다 (미정의 시 즉석 생성).
|
||||
*
|
||||
* @return LoggerInterface 로그 채널
|
||||
*/
|
||||
private static function channel(): LoggerInterface
|
||||
{
|
||||
if (config('logging.channels.browser') !== null) {
|
||||
return Log::channel('browser');
|
||||
}
|
||||
|
||||
return Log::build([
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/browser.log'),
|
||||
'level' => config('logging.level', 'debug'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\DevTools;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* G7 DevTools 상태 덤프 기록기.
|
||||
*
|
||||
* 브라우저가 전송한 디버깅 데이터를 `storage/debug-dump/` 아래 섹션별 파일로 저장한다.
|
||||
* 전송 모드는 세 가지이며 저장 결과(파일명·이력 규칙)는 모두 동일하다.
|
||||
*
|
||||
* - bulk 소용량: 모든 섹션이 단일 요청의 `sections` 배열에 담겨 온다
|
||||
* - sectional 대용량: 섹션 → 청크 단위로 쪼개 여러 요청에 나눠 온다
|
||||
* - legacy 하위 호환: 섹션명이 요청의 최상위 키로 온다
|
||||
*
|
||||
* 왜 라우트 파일이 아니라 클래스인가:
|
||||
* 원래 이 로직은 `routes/devtools.php` 안의 전역 함수 5개였다. `route:cache` 가 걸리면
|
||||
* `RouteServiceProvider::boot()` 이 `loadCachedRoutes()` 로 분기해 **라우트 파일 자체가
|
||||
* 로드되지 않는다**. 클로저는 `SerializableClosure` 로 복원되지만 전역 함수는 오토로드
|
||||
* 대상이 아니라 정의되지 않아, 핸들러가 호출하는 즉시 `Call to undefined function` 500 이
|
||||
* 된다. 클래스로 옮겨야 오토로더가 캐시 여부와 무관하게 심볼을 찾아준다.
|
||||
*
|
||||
* 왜 `app/Http/Controllers/` 가 아닌가:
|
||||
* 코어 컨트롤러는 API 레퍼런스 문서(`docs/backend/api/**`) 동반이 강제된다. 이 엔드포인트는
|
||||
* 디버그 모드 전용 개발 도구라 공개 API 표면이 아니므로, 문서 의무가 붙는 위치를 피해
|
||||
* `app/Support/` 하위 도메인 디렉토리에 둔다 (`ApiDoc/`, `Query/`, `Routing/` 과 동일 관례).
|
||||
*
|
||||
* 응답 스키마는 MCP 도구와 브라우저 클라이언트가 의존하므로 변경하지 않는다.
|
||||
*/
|
||||
class DebugDumpWriter
|
||||
{
|
||||
/**
|
||||
* 섹션명 → 저장 파일명 매핑 (SSoT).
|
||||
*
|
||||
* 세 전송 모드가 모두 이 표 하나만 본다. 과거에는 같은 29행이 세 곳에 복제되어 있어
|
||||
* 섹션 하나를 추가할 때 세 곳을 모두 고쳐야 했다.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public const SECTION_FILES = [
|
||||
'state' => 'state-latest.json',
|
||||
'actions' => 'actions-latest.json',
|
||||
'cache' => 'cache-latest.json',
|
||||
'lifecycle' => 'lifecycle-latest.json',
|
||||
'network' => 'network-latest.json',
|
||||
'expressions' => 'expressions-latest.json',
|
||||
'forms' => 'form-latest.json',
|
||||
'performance' => 'performance-latest.json',
|
||||
'conditionals' => 'conditionals-latest.json',
|
||||
'dataSources' => 'datasources-latest.json',
|
||||
'handlers' => 'handlers-latest.json',
|
||||
'componentEvents' => 'component-events-latest.json',
|
||||
'stateRendering' => 'state-rendering-latest.json',
|
||||
'stateHierarchy' => 'state-hierarchy-latest.json',
|
||||
'contextFlow' => 'context-flow-latest.json',
|
||||
'styleValidation' => 'style-validation-latest.json',
|
||||
'authDebug' => 'auth-debug-latest.json',
|
||||
'logs' => 'logs-latest.json',
|
||||
'layout' => 'layout-latest.json',
|
||||
'changeDetection' => 'change-detection-latest.json',
|
||||
'sequenceTracking' => 'sequence-latest.json',
|
||||
'staleClosureTracking' => 'stale-closure-latest.json',
|
||||
'cacheDecisionTracking' => 'cache-decisions-latest.json',
|
||||
'dataPathTransformTracking' => 'data-path-transform-latest.json',
|
||||
'nestedContextTracking' => 'nested-context-latest.json',
|
||||
'formBindingValidationTracking' => 'form-binding-validation-latest.json',
|
||||
'computedDependencyTracking' => 'computed-dependency-latest.json',
|
||||
'modalStateScopeTracking' => 'modal-state-scope-latest.json',
|
||||
'namedActionTracking' => 'named-action-tracking-latest.json',
|
||||
];
|
||||
|
||||
/** 이력 파일(`state-{timestamp}.json`)을 남기는 섹션 */
|
||||
private const HISTORY_SECTION = 'state';
|
||||
|
||||
/** 오래된 세션 디렉토리 정리 기준 (초) */
|
||||
private const SESSION_TTL_SECONDS = 600;
|
||||
|
||||
/** JSON 저장 공통 플래그 */
|
||||
private const JSON_FLAGS = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE;
|
||||
|
||||
/**
|
||||
* 섹션별 청크 분할 전송을 처리합니다 (대용량 데이터).
|
||||
*
|
||||
* @param Request $request 덤프 요청
|
||||
* @param string $debugDir 최종 저장 디렉토리
|
||||
* @param string $sessionsDir 청크 임시 디렉토리
|
||||
* @return JsonResponse 처리 결과
|
||||
*/
|
||||
public static function handleSectional(Request $request, string $debugDir, string $sessionsDir): JsonResponse
|
||||
{
|
||||
$sessionId = $request->input('sessionId');
|
||||
$sectionName = $request->input('sectionName');
|
||||
$chunkData = $request->input('chunkData');
|
||||
$chunkIndex = (int) $request->input('chunkIndex', 0);
|
||||
$totalChunks = (int) $request->input('totalChunks', 1);
|
||||
$isLastChunk = $request->boolean('isLastChunk');
|
||||
$isLastSection = $request->boolean('isLastSection');
|
||||
$timestamp = $request->input('timestamp');
|
||||
$saveHistory = $request->boolean('saveHistory');
|
||||
|
||||
// 세션 디렉토리 생성
|
||||
$sessionDir = $sessionsDir.'/'.$sessionId;
|
||||
if (! File::isDirectory($sessionDir)) {
|
||||
File::makeDirectory($sessionDir, 0755, true);
|
||||
}
|
||||
|
||||
// 청크를 임시 파일에 저장
|
||||
File::put($sessionDir.'/'.$sectionName.'.chunk.'.$chunkIndex, $chunkData);
|
||||
|
||||
// 해당 섹션의 마지막 청크이면 모든 청크를 병합
|
||||
if ($isLastChunk) {
|
||||
$mergedData = '';
|
||||
for ($i = 0; $i < $totalChunks; $i++) {
|
||||
$chunkPath = $sessionDir.'/'.$sectionName.'.chunk.'.$i;
|
||||
if (File::exists($chunkPath)) {
|
||||
$mergedData .= File::get($chunkPath);
|
||||
File::delete($chunkPath);
|
||||
}
|
||||
}
|
||||
|
||||
File::put($sessionDir.'/'.$sectionName.'.json', $mergedData);
|
||||
}
|
||||
|
||||
// 전체 덤프의 마지막 섹션이면 최종 파일 생성 후 세션 정리
|
||||
if ($isLastSection) {
|
||||
self::mergeSectionsToFinalFiles($sessionDir, $debugDir, $saveHistory, $timestamp);
|
||||
|
||||
File::deleteDirectory($sessionDir);
|
||||
self::cleanupOldSessions($sessionsDir);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'sessionId' => $sessionId,
|
||||
'sectionName' => $sectionName,
|
||||
'chunkIndex' => $chunkIndex,
|
||||
'totalChunks' => $totalChunks,
|
||||
'isLastSection' => $isLastSection,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 전송을 처리합니다 (소용량 데이터).
|
||||
*
|
||||
* @param Request $request 덤프 요청
|
||||
* @param string $debugDir 최종 저장 디렉토리
|
||||
* @return JsonResponse 처리 결과
|
||||
*/
|
||||
public static function handleBulk(Request $request, string $debugDir): JsonResponse
|
||||
{
|
||||
$sections = $request->input('sections', []);
|
||||
$saveHistory = $request->boolean('saveHistory');
|
||||
$timestampStr = self::formatTimestamp($request->input('timestamp'));
|
||||
|
||||
$savedSections = [];
|
||||
|
||||
foreach ((array) $sections as $sectionName => $sectionData) {
|
||||
if (! isset(self::SECTION_FILES[$sectionName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$json = json_encode($sectionData, self::JSON_FLAGS);
|
||||
File::put($debugDir.'/'.self::SECTION_FILES[$sectionName], $json);
|
||||
$savedSections[] = $sectionName;
|
||||
|
||||
if ($saveHistory && $sectionName === self::HISTORY_SECTION) {
|
||||
File::put($debugDir."/state-{$timestampStr}.json", $json);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'mode' => 'bulk',
|
||||
'savedSections' => $savedSections,
|
||||
'timestamp' => $timestampStr,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 레거시 전체 전송을 처리합니다 (하위 호환성 — 섹션명이 요청 최상위 키).
|
||||
*
|
||||
* @param Request $request 덤프 요청
|
||||
* @param string $debugDir 최종 저장 디렉토리
|
||||
* @return JsonResponse 처리 결과
|
||||
*/
|
||||
public static function handleLegacy(Request $request, string $debugDir): JsonResponse
|
||||
{
|
||||
$timestamp = now()->format('Ymd_His');
|
||||
$saveHistory = $request->boolean('saveHistory');
|
||||
|
||||
foreach (self::SECTION_FILES as $sectionName => $fileName) {
|
||||
if (! $request->has($sectionName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$json = json_encode($request->input($sectionName), self::JSON_FLAGS);
|
||||
File::put($debugDir.'/'.$fileName, $json);
|
||||
|
||||
if ($saveHistory && $sectionName === self::HISTORY_SECTION) {
|
||||
File::put($debugDir."/state-{$timestamp}.json", $json);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'path' => $debugDir.'/state-latest.json',
|
||||
'timestamp' => $timestamp,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 디렉토리의 섹션 파일들을 최종 저장 위치로 옮깁니다.
|
||||
*
|
||||
* @param string $sessionDir 세션 임시 디렉토리
|
||||
* @param string $debugDir 최종 저장 디렉토리
|
||||
* @param bool $saveHistory 이력 파일 저장 여부
|
||||
* @param mixed $timestamp 클라이언트가 보낸 밀리초 타임스탬프
|
||||
* @return void
|
||||
*/
|
||||
private static function mergeSectionsToFinalFiles(
|
||||
string $sessionDir,
|
||||
string $debugDir,
|
||||
bool $saveHistory,
|
||||
mixed $timestamp
|
||||
): void {
|
||||
$timestampStr = self::formatTimestamp($timestamp);
|
||||
|
||||
foreach (File::files($sessionDir) as $file) {
|
||||
$sectionName = pathinfo($file, PATHINFO_FILENAME);
|
||||
|
||||
if (! isset(self::SECTION_FILES[$sectionName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
File::copy($file, $debugDir.'/'.self::SECTION_FILES[$sectionName]);
|
||||
|
||||
if ($saveHistory && $sectionName === self::HISTORY_SECTION) {
|
||||
File::copy($file, $debugDir."/state-{$timestampStr}.json");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 오래된 세션 디렉토리를 정리합니다.
|
||||
*
|
||||
* @param string $sessionsDir 세션 루트 디렉토리
|
||||
* @return void
|
||||
*/
|
||||
private static function cleanupOldSessions(string $sessionsDir): void
|
||||
{
|
||||
if (! File::isDirectory($sessionsDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cutoffTime = time() - self::SESSION_TTL_SECONDS;
|
||||
|
||||
foreach (File::directories($sessionsDir) as $sessionDir) {
|
||||
if (File::lastModified($sessionDir) < $cutoffTime) {
|
||||
File::deleteDirectory($sessionDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 클라이언트 타임스탬프(밀리초)를 이력 파일명용 문자열로 변환합니다.
|
||||
*
|
||||
* `date()` 의 두 번째 파라미터는 int 이므로 `$timestamp / 1000` 의 float 결과를 그대로
|
||||
* 넘기면 PHP 8.1+ 에서 암묵 형변환 deprecation 이 발생한다 — 명시적으로 캐스팅한다.
|
||||
*
|
||||
* @param mixed $timestamp 밀리초 타임스탬프 (없으면 현재 시각 사용)
|
||||
* @return string `Ymd_His` 형식 문자열
|
||||
*/
|
||||
private static function formatTimestamp(mixed $timestamp): string
|
||||
{
|
||||
if (! is_numeric($timestamp)) {
|
||||
return now()->format('Ymd_His');
|
||||
}
|
||||
|
||||
return date('Ymd_His', (int) ($timestamp / 1000));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\DevTools;
|
||||
|
||||
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
||||
|
||||
/**
|
||||
* DevTools 엔드포인트 접근 게이트 (디버그 모드 판정).
|
||||
*
|
||||
* 왜 별도 클래스인가:
|
||||
* `routes/devtools.php` 의 게이트는 원래
|
||||
* `config('app.debug') || \App\Models\Setting::getValue('debug.mode', false)` 였는데,
|
||||
* **`App\Models\Setting` 은 저장소에 존재하지 않는다** (설정이 JSON 으로 이관되며 제거된 유물).
|
||||
* `||` 단축평가에 가려져 드러나지 않았을 뿐이다 — `SettingsServiceProvider` 가 `debug.mode` 를
|
||||
* `config('app.debug')` 로 동기화하므로 정상 경로에서는 두 번째 항이 평가되지 않는다.
|
||||
*
|
||||
* 그러나 settings JSON 의 `debug` 카테고리가 비면 그 동기화가 조기 반환하고, `.env` 의
|
||||
* `APP_DEBUG=false` 와 만나 **403 이어야 할 응답이 `Class "App\Models\Setting" not found`
|
||||
* 500 이 된다.** 이 게이트의 두 번째 조건은 한 번도 동작한 적이 없다.
|
||||
*
|
||||
* 판정 SSoT 는 형제 컴포넌트인 `App\Http\Middleware\SyncBoostWithDebugMode` 가 쓰는
|
||||
* `ConfigRepositoryInterface::get('debug.mode', false)` 와 일치시킨다.
|
||||
*/
|
||||
class DebugGate
|
||||
{
|
||||
/**
|
||||
* 디버그 모드 활성화 여부를 반환합니다.
|
||||
*
|
||||
* `.env` 의 `APP_DEBUG` 또는 관리자 환경설정의 `debug.mode` 중 하나라도 켜져 있으면 true.
|
||||
*
|
||||
* @return bool 디버그 모드 활성화 여부
|
||||
*/
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
if ((bool) config('app.debug')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) app(ConfigRepositoryInterface::class)->get('debug.mode', false);
|
||||
}
|
||||
}
|
||||
@@ -290,9 +290,25 @@ resources/js/core/devtools/ # 브라우저 측 DevTools 코어
|
||||
└── ui/ # DevTools 패널 UI (React)
|
||||
|
||||
routes/devtools.php # 상태 덤프 API 엔드포인트
|
||||
app/Support/DevTools/ # 엔드포인트 실제 구현 (덤프 기록 · 브라우저 로그 · 디버그 게이트)
|
||||
storage/debug-dump/ # 디버그 데이터 저장 (자동 생성, Git 제외)
|
||||
```
|
||||
|
||||
라우트 파일은 위임만 하고 로직은 `app/Support/DevTools/` 의 클래스가 갖는다. 라우트 캐시가
|
||||
걸리면 라우트 파일이 로드되지 않으므로, 파일 안에 정의된 심볼은 핸들러가 호출하는 순간
|
||||
사라진다 (상세: [routing.md "캐시 안전한 라우트 작성"](../../backend/routing.md)).
|
||||
|
||||
### 브라우저 콘솔 로그 수집 (`_boost/browser-logs`)
|
||||
|
||||
이 엔드포인트는 원래 Laravel Boost 패키지가 등록했으나 G7 가 `routes/devtools.php` 에서 직접
|
||||
소유한다. 패키지는 서비스 프로바이더 부팅 시점에 라우트를 등록하는데, 라우트 캐시가 있으면
|
||||
그 시점의 등록이 캐시 교체로 폐기되어 엔드포인트가 사라졌다. 그 결과 브라우저가 보낸 로그
|
||||
요청이 조용히 실패하고 — 실패는 스크립트 안에서 삼켜져 화면에도 로그에도 아무 흔적이 남지
|
||||
않는다 — `storage/logs/browser.log` 만 비어 있었다.
|
||||
|
||||
URI 는 `_boost/browser-logs` 로 고정한다. 이미 배포된 주입 스크립트가 이 주소를 사용하므로
|
||||
바꾸면 로그 수집이 다시 끊긴다. 디버그 모드가 꺼져 있으면 403 을 반환한다.
|
||||
|
||||
---
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
@@ -362,6 +362,32 @@ Route::prefix('products')->group(function () {
|
||||
서버 라우트에 영향을 주지 않는다. 모듈 설정의 경로 값도 마찬가지다(서버 라우트 접두사는
|
||||
`api/modules/{identifier}` 로 식별자에 고정).
|
||||
|
||||
### 캐시 안전한 라우트 작성
|
||||
|
||||
캐시가 걸리면 `RouteServiceProvider::boot()` 이 캐시 파일 로드로 분기해 **라우트 파일 자체가
|
||||
실행되지 않는다.** 라우트 핸들러 클로저는 직렬화된 형태로 복원되지만, 파일이 실행되어야만
|
||||
존재하는 심볼은 어디에도 없다.
|
||||
|
||||
깨지는 것은 클로저가 아니라 **오토로드되지 않는 심볼 참조**다. 클로저 자체는 캐시에서 정상
|
||||
동작한다 — `routes/web.php` 의 SPA catch-all 이 그 증거다. 클래스는 오토로더가 찾아주므로
|
||||
FQCN 정적 호출도 안전하다. 문제가 되는 것은 오토로드 경로가 없는 것들뿐이다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 라우트 파일에 전역 함수를 선언하고 핸들러가 호출 | 로직을 클래스(`app/Support/…` 등)로 옮기고 핸들러는 위임만 |
|
||||
| 파일 스코프 변수를 핸들러가 `use` 없이 참조 | 클래스 상수 또는 `use ($var)` 로 클로저에 캡처 |
|
||||
| 벤더/서비스 프로바이더가 조건부로 등록하는 라우트에 의존 | 그 URI 를 G7 라우트 파일이 직접 소유 |
|
||||
|
||||
전역 함수 위반의 증상은 특히 추적하기 어렵다. `Call to undefined function` 500 이 나는데
|
||||
예외의 `file` 이 `laravel-serializable-closure://` 라 **원인 파일이 스택에 드러나지 않는다.**
|
||||
정적 검사가 라우트 파일의 함수 선언을 차단한다.
|
||||
|
||||
프로바이더의 `boot()` 에서 등록한 라우트가 폐기되는 이유는 별개다. `Router::setCompiledRoutes()`
|
||||
가 라우트 컬렉션을 **통째로 교체**하고 이 교체가 `booted` 콜백에서 일어나므로, 그보다 앞서
|
||||
등록된 것은 조건 충족 여부와 무관하게 사라진다. 프레임워크 자신의 `BroadcastManager::routes()`
|
||||
는 캐시 여부를 확인하는 가드를 갖고 있지만, 모든 패키지가 그렇지는 않다. 그런 라우트에
|
||||
기능이 의존한다면 해당 URI 를 G7 라우트 파일에서 등록해 캐시에 함께 구워지게 한다.
|
||||
|
||||
---
|
||||
|
||||
## 개발 체크리스트
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.11] - 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
- 개발자 도구 응답 문구 일본어 번역 추가 — 디버그 모드 접근 거부, 연결 확인, 덤프 데이터 없음 안내, 데이터 삭제 완료 안내가 일본어 로케일에서 번역문으로 표시됩니다. 종전에는 사이트 언어와 무관하게 항상 한국어로만 나왔습니다.
|
||||
|
||||
## [1.0.10] - 2026-08-04
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'debug_disabled' => 'デバッグモードが無効化されています。',
|
||||
'connection_ok' => '接続に成功しました。',
|
||||
'no_state' => '状態ダンプファイルがありません。ブラウザーで G7DevTools.server.dumpState() を実行してください。',
|
||||
'no_actions' => 'アクション履歴ファイルがありません。',
|
||||
'no_cache' => 'キャッシュ統計ファイルがありません。',
|
||||
'no_change_detection' => '変更検知データがありません。ブラウザーで G7DevTools.server.dumpState() を実行してください。',
|
||||
'cleared' => 'デバッグデータが削除されました。',
|
||||
];
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 core Japanese language pack (bundled)",
|
||||
"ja": "G7 コア 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"license": "MIT",
|
||||
"scope": "core",
|
||||
"target_identifier": null,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
// G7 DevTools 엔드포인트(routes/devtools.php) 응답 문구.
|
||||
// 디버그 모드에서만 동작하는 개발 도구 전용이며, MCP 도구와 브라우저 클라이언트가 소비한다.
|
||||
// 응답의 `status` 값은 계약이므로 번역 대상이 아니다 — 사람이 읽는 `message` 만 여기 둔다.
|
||||
return [
|
||||
// Access gate
|
||||
'debug_disabled' => 'Debug mode is disabled.',
|
||||
'connection_ok' => 'Connected',
|
||||
|
||||
// Dump data lookup (for MCP tools)
|
||||
'no_state' => 'No state dump file found. Run G7DevTools.server.dumpState() in the browser.',
|
||||
'no_actions' => 'No action history file found.',
|
||||
'no_cache' => 'No cache statistics file found.',
|
||||
'no_change_detection' => 'No change detection data found. Run G7DevTools.server.dumpState() in the browser.',
|
||||
|
||||
// Data removal
|
||||
'cleared' => 'Debug data has been cleared.',
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
// G7 DevTools 엔드포인트(routes/devtools.php) 응답 문구.
|
||||
// 디버그 모드에서만 동작하는 개발 도구 전용이며, MCP 도구와 브라우저 클라이언트가 소비한다.
|
||||
// 응답의 `status` 값은 계약이므로 번역 대상이 아니다 — 사람이 읽는 `message` 만 여기 둔다.
|
||||
return [
|
||||
// 접근 게이트
|
||||
'debug_disabled' => '디버그 모드가 비활성화되어 있습니다.',
|
||||
'connection_ok' => '연결 성공',
|
||||
|
||||
// 덤프 데이터 조회 (MCP 도구용)
|
||||
'no_state' => '상태 덤프 파일이 없습니다. 브라우저에서 G7DevTools.server.dumpState()를 실행하세요.',
|
||||
'no_actions' => '액션 이력 파일이 없습니다.',
|
||||
'no_cache' => '캐시 통계 파일이 없습니다.',
|
||||
'no_change_detection' => '변경 감지 데이터가 없습니다. 브라우저에서 G7DevTools.server.dumpState()를 실행하세요.',
|
||||
|
||||
// 데이터 삭제
|
||||
'cleared' => '디버그 데이터가 삭제되었습니다.',
|
||||
];
|
||||
+52
-457
@@ -5,8 +5,23 @@
|
||||
*
|
||||
* 디버깅 데이터 덤프 및 로그 전송 엔드포인트
|
||||
* 디버그 모드가 활성화된 경우에만 동작
|
||||
*
|
||||
* 라우트 캐시 안전성:
|
||||
* `route:cache` 가 걸리면 이 파일은 **로드되지 않는다** — `RouteServiceProvider::boot()` 이
|
||||
* `loadCachedRoutes()` 로 분기하고 클로저만 `SerializableClosure` 로 복원된다. 따라서 이
|
||||
* 파일 안에 전역 함수나 파일 스코프 변수를 정의하고 핸들러에서 참조하면 캐시 상태에서
|
||||
* `Call to undefined function` 500 이 된다. 실제 로직은 전부 오토로드되는 클래스
|
||||
* (`App\Support\DevTools\*`) 에 두고, 여기서는 위임만 한다.
|
||||
*
|
||||
* 반면 클래스 호출은 `use` 별칭이든 FQCN 이든 안전하다. `SerializableClosure` 는 **직렬화
|
||||
* 시점**(= `route:cache` 실행 중, 이 파일이 로드되어 있는 시점) 에 소스를 파싱해 import 를
|
||||
* 해석하고 완전 수식명으로 치환한 코드를 굽는다. 역직렬화 때는 해석할 import 가 남아 있지
|
||||
* 않다. `DevtoolsRouteCacheTest` 가 캐시된 클로저로 이 위임들을 실제 호출해 검증한다.
|
||||
*/
|
||||
|
||||
use App\Support\DevTools\BrowserLogWriter;
|
||||
use App\Support\DevTools\DebugDumpWriter;
|
||||
use App\Support\DevTools\DebugGate;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
@@ -14,451 +29,38 @@ use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| DevTools Helper Functions
|
||||
| Browser Logs (boost.browser-logs)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 섹션별 분할 전송 처리를 위한 헬퍼 함수들
|
||||
| 이 라우트는 원래 `Laravel\Boost\BoostServiceProvider::boot()` 이 등록한다.
|
||||
| 그런데 `Router::setCompiledRoutes()` 는 `booted` 콜백에서 라우트 컬렉션을 통째로
|
||||
| 교체하므로, 라우트 캐시가 있으면 프로바이더 boot() 에서 등록한 라우트는 조건 충족
|
||||
| 여부와 무관하게 전부 폐기된다. 그 결과 `routes/web.php` 의 SPA catch-all(GET 전용)만
|
||||
| 남아 POST 가 405 로 튕기고, `BrowserLogger` 는 `Route::has('boost.browser-logs')` 가
|
||||
| false 라 폴백 리터럴 URL 을 굽는다 — fetch 실패는 `.catch` 로 삼켜져 오류 한 줄 없이
|
||||
| 브라우저 로그 수집만 사라진다.
|
||||
|
|
||||
| 그래서 G7 가 이 URI 를 직접 소유해 캐시에 함께 구워지게 한다. "boost 가 등록하는데 왜
|
||||
| 중복으로 두었나" 라는 오해로 제거하지 말 것. 캐시가 없을 때는 boost 도 같은 URI 를
|
||||
| 등록하지만 `RouteCollection::addToCollections()` 가 method+URI 키로 덮어쓰므로 물리적
|
||||
| 라우트는 1개만 남는다.
|
||||
|
|
||||
| URI 는 `_boost/browser-logs` 고정이다 — `BrowserLogger` 의 폴백 리터럴 및 이미 배포된
|
||||
| 인젝션 스크립트와 일치해야 한다. CSRF 면제는 불필요하다: `bootstrap/app.php` 가 이 파일을
|
||||
| `api` 그룹으로 감싸므로 `VerifyCsrfToken` 이 애초에 걸리지 않는다.
|
||||
|
|
||||
*/
|
||||
|
||||
if (! function_exists('handleSectionalDump')) {
|
||||
/**
|
||||
* 섹션별 청크 분할 전송 처리
|
||||
*/
|
||||
function handleSectionalDump(Request $request, string $debugDir, string $sessionsDir): JsonResponse
|
||||
{
|
||||
$sessionId = $request->input('sessionId');
|
||||
$sectionName = $request->input('sectionName');
|
||||
$chunkData = $request->input('chunkData');
|
||||
$chunkIndex = (int) $request->input('chunkIndex', 0);
|
||||
$totalChunks = (int) $request->input('totalChunks', 1);
|
||||
$isLastChunk = $request->boolean('isLastChunk');
|
||||
$isLastSection = $request->boolean('isLastSection');
|
||||
$timestamp = $request->input('timestamp');
|
||||
$saveHistory = $request->boolean('saveHistory');
|
||||
$totalSections = $request->input('totalSections', 1);
|
||||
|
||||
// 세션 디렉토리 생성
|
||||
$sessionDir = $sessionsDir.'/'.$sessionId;
|
||||
if (! File::isDirectory($sessionDir)) {
|
||||
File::makeDirectory($sessionDir, 0755, true);
|
||||
}
|
||||
|
||||
// 청크를 임시 파일에 저장
|
||||
$chunkFile = $sessionDir.'/'.$sectionName.'.chunk.'.$chunkIndex;
|
||||
File::put($chunkFile, $chunkData);
|
||||
|
||||
// 해당 섹션의 마지막 청크이면 모든 청크를 병합
|
||||
if ($isLastChunk) {
|
||||
$mergedData = '';
|
||||
for ($i = 0; $i < $totalChunks; $i++) {
|
||||
$chunkPath = $sessionDir.'/'.$sectionName.'.chunk.'.$i;
|
||||
if (File::exists($chunkPath)) {
|
||||
$mergedData .= File::get($chunkPath);
|
||||
File::delete($chunkPath);
|
||||
}
|
||||
}
|
||||
|
||||
// 병합된 JSON을 섹션 파일로 저장
|
||||
$sectionFile = $sessionDir.'/'.$sectionName.'.json';
|
||||
File::put($sectionFile, $mergedData);
|
||||
}
|
||||
|
||||
// 전체 덤프의 마지막 섹션/청크이면 최종 파일 생성
|
||||
if ($isLastSection) {
|
||||
mergeSectionsToFinalFiles($sessionDir, $debugDir, $saveHistory, $timestamp);
|
||||
|
||||
// 세션 디렉토리 정리
|
||||
File::deleteDirectory($sessionDir);
|
||||
|
||||
// 오래된 세션 정리 (10분 이상 된 세션)
|
||||
cleanupOldSessions($sessionsDir);
|
||||
}
|
||||
|
||||
Route::post('_boost/browser-logs', function (Request $request): JsonResponse {
|
||||
if (! DebugGate::isEnabled()) {
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'sessionId' => $sessionId,
|
||||
'sectionName' => $sectionName,
|
||||
'chunkIndex' => $chunkIndex,
|
||||
'totalChunks' => $totalChunks,
|
||||
'isLastSection' => $isLastSection,
|
||||
]);
|
||||
'status' => 'error',
|
||||
'message' => __('devtools.debug_disabled'),
|
||||
], 403);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('mergeSectionsToFinalFiles')) {
|
||||
/**
|
||||
* 섹션 파일들을 최종 파일로 병합
|
||||
*/
|
||||
function mergeSectionsToFinalFiles(string $sessionDir, string $debugDir, bool $saveHistory, $timestamp): void
|
||||
{
|
||||
// 섹션명 -> 파일명 매핑
|
||||
$sectionToFile = [
|
||||
'state' => 'state-latest.json',
|
||||
'actions' => 'actions-latest.json',
|
||||
'cache' => 'cache-latest.json',
|
||||
'lifecycle' => 'lifecycle-latest.json',
|
||||
'network' => 'network-latest.json',
|
||||
'expressions' => 'expressions-latest.json',
|
||||
'forms' => 'form-latest.json',
|
||||
'performance' => 'performance-latest.json',
|
||||
'conditionals' => 'conditionals-latest.json',
|
||||
'dataSources' => 'datasources-latest.json',
|
||||
'handlers' => 'handlers-latest.json',
|
||||
'componentEvents' => 'component-events-latest.json',
|
||||
'stateRendering' => 'state-rendering-latest.json',
|
||||
'stateHierarchy' => 'state-hierarchy-latest.json',
|
||||
'contextFlow' => 'context-flow-latest.json',
|
||||
'styleValidation' => 'style-validation-latest.json',
|
||||
'authDebug' => 'auth-debug-latest.json',
|
||||
'logs' => 'logs-latest.json',
|
||||
'layout' => 'layout-latest.json',
|
||||
'changeDetection' => 'change-detection-latest.json',
|
||||
'sequenceTracking' => 'sequence-latest.json',
|
||||
'staleClosureTracking' => 'stale-closure-latest.json',
|
||||
'cacheDecisionTracking' => 'cache-decisions-latest.json',
|
||||
'dataPathTransformTracking' => 'data-path-transform-latest.json',
|
||||
'nestedContextTracking' => 'nested-context-latest.json',
|
||||
'formBindingValidationTracking' => 'form-binding-validation-latest.json',
|
||||
'computedDependencyTracking' => 'computed-dependency-latest.json',
|
||||
'modalStateScopeTracking' => 'modal-state-scope-latest.json',
|
||||
'namedActionTracking' => 'named-action-tracking-latest.json',
|
||||
];
|
||||
|
||||
$timestampStr = $timestamp ? date('Ymd_His', $timestamp / 1000) : now()->format('Ymd_His');
|
||||
|
||||
// 각 섹션 파일을 최종 위치로 이동
|
||||
foreach (File::files($sessionDir) as $file) {
|
||||
$sectionName = pathinfo($file, PATHINFO_FILENAME);
|
||||
|
||||
if (isset($sectionToFile[$sectionName])) {
|
||||
$targetFile = $debugDir.'/'.$sectionToFile[$sectionName];
|
||||
File::copy($file, $targetFile);
|
||||
|
||||
// 이력 파일 저장 (state 섹션만)
|
||||
if ($saveHistory && $sectionName === 'state') {
|
||||
File::copy($file, $debugDir."/state-{$timestampStr}.json");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('cleanupOldSessions')) {
|
||||
/**
|
||||
* 오래된 세션 디렉토리 정리 (10분 이상)
|
||||
*/
|
||||
function cleanupOldSessions(string $sessionsDir): void
|
||||
{
|
||||
if (! File::isDirectory($sessionsDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cutoffTime = time() - (10 * 60); // 10분 전
|
||||
|
||||
foreach (File::directories($sessionsDir) as $sessionDir) {
|
||||
if (File::lastModified($sessionDir) < $cutoffTime) {
|
||||
File::deleteDirectory($sessionDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('handleBulkDump')) {
|
||||
/**
|
||||
* 일괄 전송 처리 (소용량 데이터용)
|
||||
*
|
||||
* 모든 섹션이 단일 요청에 포함됨
|
||||
*/
|
||||
function handleBulkDump(Request $request, string $debugDir): JsonResponse
|
||||
{
|
||||
$sections = $request->input('sections', []);
|
||||
$timestamp = $request->input('timestamp');
|
||||
$saveHistory = $request->boolean('saveHistory');
|
||||
|
||||
$timestampStr = $timestamp ? date('Ymd_His', $timestamp / 1000) : now()->format('Ymd_His');
|
||||
|
||||
// 섹션명 -> 파일명 매핑
|
||||
$sectionToFile = [
|
||||
'state' => 'state-latest.json',
|
||||
'actions' => 'actions-latest.json',
|
||||
'cache' => 'cache-latest.json',
|
||||
'lifecycle' => 'lifecycle-latest.json',
|
||||
'network' => 'network-latest.json',
|
||||
'expressions' => 'expressions-latest.json',
|
||||
'forms' => 'form-latest.json',
|
||||
'performance' => 'performance-latest.json',
|
||||
'conditionals' => 'conditionals-latest.json',
|
||||
'dataSources' => 'datasources-latest.json',
|
||||
'handlers' => 'handlers-latest.json',
|
||||
'componentEvents' => 'component-events-latest.json',
|
||||
'stateRendering' => 'state-rendering-latest.json',
|
||||
'stateHierarchy' => 'state-hierarchy-latest.json',
|
||||
'contextFlow' => 'context-flow-latest.json',
|
||||
'styleValidation' => 'style-validation-latest.json',
|
||||
'authDebug' => 'auth-debug-latest.json',
|
||||
'logs' => 'logs-latest.json',
|
||||
'layout' => 'layout-latest.json',
|
||||
'changeDetection' => 'change-detection-latest.json',
|
||||
'sequenceTracking' => 'sequence-latest.json',
|
||||
'staleClosureTracking' => 'stale-closure-latest.json',
|
||||
'cacheDecisionTracking' => 'cache-decisions-latest.json',
|
||||
'dataPathTransformTracking' => 'data-path-transform-latest.json',
|
||||
'nestedContextTracking' => 'nested-context-latest.json',
|
||||
'formBindingValidationTracking' => 'form-binding-validation-latest.json',
|
||||
'computedDependencyTracking' => 'computed-dependency-latest.json',
|
||||
'modalStateScopeTracking' => 'modal-state-scope-latest.json',
|
||||
'namedActionTracking' => 'named-action-tracking-latest.json',
|
||||
];
|
||||
|
||||
$savedSections = [];
|
||||
|
||||
foreach ($sections as $sectionName => $sectionData) {
|
||||
if (isset($sectionToFile[$sectionName])) {
|
||||
$json = json_encode($sectionData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
$targetFile = $debugDir.'/'.$sectionToFile[$sectionName];
|
||||
File::put($targetFile, $json);
|
||||
$savedSections[] = $sectionName;
|
||||
|
||||
// 이력 파일 저장 (state 섹션만)
|
||||
if ($saveHistory && $sectionName === 'state') {
|
||||
File::put($debugDir."/state-{$timestampStr}.json", $json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'mode' => 'bulk',
|
||||
'savedSections' => $savedSections,
|
||||
'timestamp' => $timestampStr,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('handleLegacyDump')) {
|
||||
/**
|
||||
* 레거시 전체 전송 처리 (하위 호환성)
|
||||
*/
|
||||
function handleLegacyDump(Request $request, string $debugDir): JsonResponse
|
||||
{
|
||||
$timestamp = now()->format('Ymd_His');
|
||||
|
||||
// 상태 데이터 저장
|
||||
if ($request->has('state')) {
|
||||
$stateData = $request->input('state');
|
||||
$stateJson = json_encode($stateData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
File::put($debugDir.'/state-latest.json', $stateJson);
|
||||
|
||||
if ($request->boolean('saveHistory')) {
|
||||
File::put($debugDir."/state-{$timestamp}.json", $stateJson);
|
||||
}
|
||||
}
|
||||
|
||||
// 액션 이력 저장
|
||||
if ($request->has('actions')) {
|
||||
$actionsData = $request->input('actions');
|
||||
$actionsJson = json_encode($actionsData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/actions-latest.json', $actionsJson);
|
||||
}
|
||||
|
||||
// 캐시 통계 저장
|
||||
if ($request->has('cache')) {
|
||||
$cacheData = $request->input('cache');
|
||||
$cacheJson = json_encode($cacheData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/cache-latest.json', $cacheJson);
|
||||
}
|
||||
|
||||
// 라이프사이클 정보 저장
|
||||
if ($request->has('lifecycle')) {
|
||||
$lifecycleData = $request->input('lifecycle');
|
||||
$lifecycleJson = json_encode($lifecycleData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/lifecycle-latest.json', $lifecycleJson);
|
||||
}
|
||||
|
||||
// 네트워크 정보 저장
|
||||
if ($request->has('network')) {
|
||||
$networkData = $request->input('network');
|
||||
$networkJson = json_encode($networkData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/network-latest.json', $networkJson);
|
||||
}
|
||||
|
||||
// 표현식 정보 저장
|
||||
if ($request->has('expressions')) {
|
||||
$expressionsData = $request->input('expressions');
|
||||
$expressionsJson = json_encode($expressionsData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/expressions-latest.json', $expressionsJson);
|
||||
}
|
||||
|
||||
// 폼 정보 저장
|
||||
if ($request->has('forms')) {
|
||||
$formsData = $request->input('forms');
|
||||
$formsJson = json_encode($formsData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/form-latest.json', $formsJson);
|
||||
}
|
||||
|
||||
// 성능 정보 저장
|
||||
if ($request->has('performance')) {
|
||||
$performanceData = $request->input('performance');
|
||||
$performanceJson = json_encode($performanceData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/performance-latest.json', $performanceJson);
|
||||
}
|
||||
|
||||
// 조건부 렌더링 정보 저장
|
||||
if ($request->has('conditionals')) {
|
||||
$conditionalsData = $request->input('conditionals');
|
||||
$conditionalsJson = json_encode($conditionalsData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/conditionals-latest.json', $conditionalsJson);
|
||||
}
|
||||
|
||||
// 데이터소스 정보 저장
|
||||
if ($request->has('dataSources')) {
|
||||
$dataSourcesData = $request->input('dataSources');
|
||||
$dataSourcesJson = json_encode($dataSourcesData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/datasources-latest.json', $dataSourcesJson);
|
||||
}
|
||||
|
||||
// 핸들러 정보 저장
|
||||
if ($request->has('handlers')) {
|
||||
$handlersData = $request->input('handlers');
|
||||
$handlersJson = json_encode($handlersData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/handlers-latest.json', $handlersJson);
|
||||
}
|
||||
|
||||
// 컴포넌트 이벤트 정보 저장
|
||||
if ($request->has('componentEvents')) {
|
||||
$componentEventsData = $request->input('componentEvents');
|
||||
$componentEventsJson = json_encode($componentEventsData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/component-events-latest.json', $componentEventsJson);
|
||||
}
|
||||
|
||||
// 상태-렌더링 정보 저장
|
||||
if ($request->has('stateRendering')) {
|
||||
$stateRenderingData = $request->input('stateRendering');
|
||||
$stateRenderingJson = json_encode($stateRenderingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/state-rendering-latest.json', $stateRenderingJson);
|
||||
}
|
||||
|
||||
// 상태 계층 정보 저장
|
||||
if ($request->has('stateHierarchy')) {
|
||||
$stateHierarchyData = $request->input('stateHierarchy');
|
||||
$stateHierarchyJson = json_encode($stateHierarchyData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/state-hierarchy-latest.json', $stateHierarchyJson);
|
||||
}
|
||||
|
||||
// 컨텍스트 플로우 정보 저장
|
||||
if ($request->has('contextFlow')) {
|
||||
$contextFlowData = $request->input('contextFlow');
|
||||
$contextFlowJson = json_encode($contextFlowData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/context-flow-latest.json', $contextFlowJson);
|
||||
}
|
||||
|
||||
// 스타일 검증 정보 저장
|
||||
if ($request->has('styleValidation')) {
|
||||
$styleValidationData = $request->input('styleValidation');
|
||||
$styleValidationJson = json_encode($styleValidationData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/style-validation-latest.json', $styleValidationJson);
|
||||
}
|
||||
|
||||
// 인증 디버깅 정보 저장
|
||||
if ($request->has('authDebug')) {
|
||||
$authDebugData = $request->input('authDebug');
|
||||
$authDebugJson = json_encode($authDebugData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/auth-debug-latest.json', $authDebugJson);
|
||||
}
|
||||
|
||||
// 로그 정보 저장
|
||||
if ($request->has('logs')) {
|
||||
$logsData = $request->input('logs');
|
||||
$logsJson = json_encode($logsData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/logs-latest.json', $logsJson);
|
||||
}
|
||||
|
||||
// 레이아웃 정보 저장
|
||||
if ($request->has('layout')) {
|
||||
$layoutData = $request->input('layout');
|
||||
$layoutJson = json_encode($layoutData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/layout-latest.json', $layoutJson);
|
||||
}
|
||||
|
||||
// 변경 감지 정보 저장
|
||||
if ($request->has('changeDetection')) {
|
||||
$changeDetectionData = $request->input('changeDetection');
|
||||
$changeDetectionJson = json_encode($changeDetectionData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/change-detection-latest.json', $changeDetectionJson);
|
||||
}
|
||||
|
||||
// Sequence 추적 정보 저장
|
||||
if ($request->has('sequenceTracking')) {
|
||||
$sequenceTrackingData = $request->input('sequenceTracking');
|
||||
$sequenceTrackingJson = json_encode($sequenceTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/sequence-latest.json', $sequenceTrackingJson);
|
||||
}
|
||||
|
||||
// Stale Closure 추적 정보 저장
|
||||
if ($request->has('staleClosureTracking')) {
|
||||
$staleClosureTrackingData = $request->input('staleClosureTracking');
|
||||
$staleClosureTrackingJson = json_encode($staleClosureTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/stale-closure-latest.json', $staleClosureTrackingJson);
|
||||
}
|
||||
|
||||
// 캐시 결정 추적 정보 저장
|
||||
if ($request->has('cacheDecisionTracking')) {
|
||||
$cacheDecisionTrackingData = $request->input('cacheDecisionTracking');
|
||||
$cacheDecisionTrackingJson = json_encode($cacheDecisionTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/cache-decisions-latest.json', $cacheDecisionTrackingJson);
|
||||
}
|
||||
|
||||
// 데이터 경로 변환 추적 정보 저장
|
||||
if ($request->has('dataPathTransformTracking')) {
|
||||
$dataPathTransformTrackingData = $request->input('dataPathTransformTracking');
|
||||
$dataPathTransformTrackingJson = json_encode($dataPathTransformTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/data-path-transform-latest.json', $dataPathTransformTrackingJson);
|
||||
}
|
||||
|
||||
// Nested Context 추적 정보 저장
|
||||
if ($request->has('nestedContextTracking')) {
|
||||
$nestedContextTrackingData = $request->input('nestedContextTracking');
|
||||
$nestedContextTrackingJson = json_encode($nestedContextTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/nested-context-latest.json', $nestedContextTrackingJson);
|
||||
}
|
||||
|
||||
// Form 바인딩 검증 추적 정보 저장
|
||||
if ($request->has('formBindingValidationTracking')) {
|
||||
$formBindingValidationTrackingData = $request->input('formBindingValidationTracking');
|
||||
$formBindingValidationTrackingJson = json_encode($formBindingValidationTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/form-binding-validation-latest.json', $formBindingValidationTrackingJson);
|
||||
}
|
||||
|
||||
// Computed 의존성 추적 정보 저장
|
||||
if ($request->has('computedDependencyTracking')) {
|
||||
$computedDependencyTrackingData = $request->input('computedDependencyTracking');
|
||||
$computedDependencyTrackingJson = json_encode($computedDependencyTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/computed-dependency-latest.json', $computedDependencyTrackingJson);
|
||||
}
|
||||
|
||||
// 모달 상태 스코프 추적 정보 저장
|
||||
if ($request->has('modalStateScopeTracking')) {
|
||||
$modalStateScopeTrackingData = $request->input('modalStateScopeTracking');
|
||||
$modalStateScopeTrackingJson = json_encode($modalStateScopeTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/modal-state-scope-latest.json', $modalStateScopeTrackingJson);
|
||||
}
|
||||
|
||||
// Named Action 추적 정보 저장
|
||||
if ($request->has('namedActionTracking')) {
|
||||
$namedActionTrackingData = $request->input('namedActionTracking');
|
||||
$namedActionTrackingJson = json_encode($namedActionTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($debugDir.'/named-action-tracking-latest.json', $namedActionTrackingJson);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'path' => $debugDir.'/state-latest.json',
|
||||
'timestamp' => $timestamp,
|
||||
]);
|
||||
}
|
||||
}
|
||||
return BrowserLogWriter::handle($request);
|
||||
})->name('boost.browser-logs');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -478,13 +80,10 @@ Route::prefix('_boost/g7-debug')->middleware('api')->group(function () {
|
||||
* 섹션별 분할 전송을 지원합니다.
|
||||
*/
|
||||
Route::post('dump-state', function (Request $request): JsonResponse {
|
||||
// 디버그 모드 확인 (환경설정 또는 .env)
|
||||
$debugMode = config('app.debug') || \App\Models\Setting::getValue('debug.mode', false);
|
||||
|
||||
if (! $debugMode) {
|
||||
if (! DebugGate::isEnabled()) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '디버그 모드가 비활성화되어 있습니다.',
|
||||
'message' => __('devtools.debug_disabled'),
|
||||
], 403);
|
||||
}
|
||||
|
||||
@@ -492,30 +91,29 @@ Route::prefix('_boost/g7-debug')->middleware('api')->group(function () {
|
||||
if ($request->boolean('test')) {
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '연결 성공',
|
||||
'message' => __('devtools.connection_ok'),
|
||||
]);
|
||||
}
|
||||
|
||||
$debugDir = storage_path('debug-dump');
|
||||
$sessionsDir = $debugDir.'/sessions';
|
||||
|
||||
// 디렉토리 생성
|
||||
if (! File::isDirectory($debugDir)) {
|
||||
File::makeDirectory($debugDir, 0755, true);
|
||||
}
|
||||
|
||||
// 일괄 전송 처리 (소용량 데이터)
|
||||
if ($request->boolean('bulk') && $request->has('sections')) {
|
||||
return handleBulkDump($request, $debugDir);
|
||||
return DebugDumpWriter::handleBulk($request, $debugDir);
|
||||
}
|
||||
|
||||
// 섹션별 분할 전송 처리 (대용량 데이터)
|
||||
if ($request->has('sessionId') && $request->has('sectionName')) {
|
||||
return handleSectionalDump($request, $debugDir, $sessionsDir);
|
||||
return DebugDumpWriter::handleSectional($request, $debugDir, $sessionsDir);
|
||||
}
|
||||
|
||||
// 레거시 전체 전송 처리 (하위 호환성)
|
||||
return handleLegacyDump($request, $debugDir);
|
||||
return DebugDumpWriter::handleLegacy($request, $debugDir);
|
||||
})->name('devtools.dump-state');
|
||||
|
||||
/**
|
||||
@@ -524,13 +122,10 @@ Route::prefix('_boost/g7-debug')->middleware('api')->group(function () {
|
||||
* 디버그 로그, 에러, 프로파일 데이터를 저장합니다.
|
||||
*/
|
||||
Route::post('log', function (Request $request): JsonResponse {
|
||||
// 디버그 모드 확인
|
||||
$debugMode = config('app.debug') || \App\Models\Setting::getValue('debug.mode', false);
|
||||
|
||||
if (! $debugMode) {
|
||||
if (! DebugGate::isEnabled()) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '디버그 모드가 비활성화되어 있습니다.',
|
||||
'message' => __('devtools.debug_disabled'),
|
||||
], 403);
|
||||
}
|
||||
|
||||
@@ -593,7 +188,7 @@ Route::prefix('_boost/g7-debug')->middleware('api')->group(function () {
|
||||
if (! File::exists($stateFile)) {
|
||||
return response()->json([
|
||||
'status' => 'no_data',
|
||||
'message' => '상태 덤프 파일이 없습니다. 브라우저에서 G7DevTools.server.dumpState()를 실행하세요.',
|
||||
'message' => __('devtools.no_state'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -616,7 +211,7 @@ Route::prefix('_boost/g7-debug')->middleware('api')->group(function () {
|
||||
if (! File::exists($actionsFile)) {
|
||||
return response()->json([
|
||||
'status' => 'no_data',
|
||||
'message' => '액션 이력 파일이 없습니다.',
|
||||
'message' => __('devtools.no_actions'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -654,7 +249,7 @@ Route::prefix('_boost/g7-debug')->middleware('api')->group(function () {
|
||||
if (! File::exists($cacheFile)) {
|
||||
return response()->json([
|
||||
'status' => 'no_data',
|
||||
'message' => '캐시 통계 파일이 없습니다.',
|
||||
'message' => __('devtools.no_cache'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -676,7 +271,7 @@ Route::prefix('_boost/g7-debug')->middleware('api')->group(function () {
|
||||
if (! File::exists($changeDetectionFile)) {
|
||||
return response()->json([
|
||||
'status' => 'no_data',
|
||||
'message' => '변경 감지 데이터가 없습니다. 브라우저에서 G7DevTools.server.dumpState()를 실행하세요.',
|
||||
'message' => __('devtools.no_change_detection'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -727,7 +322,7 @@ Route::prefix('_boost/g7-debug')->middleware('api')->group(function () {
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '디버그 데이터가 삭제되었습니다.',
|
||||
'message' => __('devtools.cleared'),
|
||||
]);
|
||||
})->name('devtools.clear');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\DevTools;
|
||||
|
||||
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
||||
use App\Support\DevTools\DebugGate;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* DevTools 엔드포인트의 디버그 모드 게이트 회귀 테스트.
|
||||
*
|
||||
* 배경:
|
||||
* 게이트는 원래 `config('app.debug') || \App\Models\Setting::getValue('debug.mode', false)`
|
||||
* 였는데 `App\Models\Setting` 은 저장소에 존재하지 않는 유물이었다. `||` 단축평가 덕에
|
||||
* `APP_DEBUG=true` 인 동안에는 두 번째 항이 평가되지 않아 드러나지 않았을 뿐이고,
|
||||
* `APP_DEBUG=false` + settings JSON 의 `debug` 카테고리 미동기화가 겹치면
|
||||
* **403 이어야 할 응답이 `Class "App\Models\Setting" not found` 500 이 됐다.**
|
||||
*
|
||||
* 검증 축:
|
||||
* 1. app.debug=false + debug.mode=true → 200 (두 번째 항이 실제로 평가되어야 한다)
|
||||
* 2. app.debug=false + debug.mode=false → 403 (500 이 아니어야 한다)
|
||||
* 3. app.debug=true → 200 (환경설정 조회 없이 통과)
|
||||
*/
|
||||
class DevtoolsDebugGateTest extends TestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* `debug.mode` 환경설정만 켜져 있어도 게이트를 통과해야 한다.
|
||||
*
|
||||
* 수정 전: HTTP 500 `Class "App\Models\Setting" not found`
|
||||
*/
|
||||
public function test_dump_state_passes_gate_via_settings_debug_mode(): void
|
||||
{
|
||||
config(['app.debug' => false]);
|
||||
$this->mockConfigRepositoryDebugMode(true);
|
||||
|
||||
$response = $this->postJson('/_boost/g7-debug/dump-state', ['test' => true]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['status' => 'success']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 양쪽 모두 꺼져 있으면 403 이어야 한다 (500 이 아니라).
|
||||
*/
|
||||
public function test_dump_state_returns_403_when_debug_disabled(): void
|
||||
{
|
||||
config(['app.debug' => false]);
|
||||
$this->mockConfigRepositoryDebugMode(false);
|
||||
|
||||
$response = $this->postJson('/_boost/g7-debug/dump-state', ['test' => true]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertJson(['status' => 'error']);
|
||||
|
||||
// 번역 키가 해석되지 않으면 Laravel 은 키 문자열을 그대로 돌려준다 — 오류 없이
|
||||
// 사용자에게 `devtools.debug_disabled` 가 노출되므로 해석 여부를 단언한다.
|
||||
$this->assertNotSame(
|
||||
'devtools.debug_disabled',
|
||||
$response->json('message'),
|
||||
'번역 키가 해석되지 않았습니다 (lang/{ko,en}/devtools.php 누락).'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `browser-logs` 도 동일 게이트를 사용한다.
|
||||
*/
|
||||
public function test_browser_logs_returns_403_when_debug_disabled(): void
|
||||
{
|
||||
config(['app.debug' => false]);
|
||||
$this->mockConfigRepositoryDebugMode(false);
|
||||
|
||||
$response = $this->postJson('/_boost/browser-logs', ['logs' => []]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* `.env` 의 `APP_DEBUG=true` 만으로도 통과한다 (환경설정 조회 불필요).
|
||||
*/
|
||||
public function test_gate_passes_on_app_debug_without_settings_lookup(): void
|
||||
{
|
||||
config(['app.debug' => true]);
|
||||
|
||||
$repository = Mockery::mock(ConfigRepositoryInterface::class);
|
||||
$repository->shouldNotReceive('get');
|
||||
$this->app->instance(ConfigRepositoryInterface::class, $repository);
|
||||
|
||||
$this->assertTrue(DebugGate::isEnabled());
|
||||
}
|
||||
|
||||
/**
|
||||
* `ConfigRepositoryInterface` 를 `debug.mode` 고정값으로 대체한다.
|
||||
*
|
||||
* @param bool $enabled debug.mode 값
|
||||
* @return void
|
||||
*/
|
||||
private function mockConfigRepositoryDebugMode(bool $enabled): void
|
||||
{
|
||||
$repository = Mockery::mock(ConfigRepositoryInterface::class);
|
||||
$repository->shouldReceive('get')
|
||||
->with('debug.mode', false)
|
||||
->andReturn($enabled);
|
||||
$repository->shouldReceive('get')->andReturnUsing(
|
||||
static fn (string $key, mixed $default = null): mixed => $default
|
||||
);
|
||||
|
||||
$this->app->instance(ConfigRepositoryInterface::class, $repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\DevTools;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 라우트 캐시(`bootstrap/cache/routes-v7.php`)가 걸린 상태에서도 DevTools 덤프와
|
||||
* boost browser-logs 엔드포인트가 동작함을 실증한다.
|
||||
*
|
||||
* 배경:
|
||||
* `route:cache` 가 걸리면 `RouteServiceProvider::boot()` 이 `loadCachedRoutes()` 로 분기해
|
||||
* **라우트 파일 자체가 로드되지 않는다**. 클로저는 `SerializableClosure` 로 복원되지만
|
||||
* 그 클로저가 참조하는 전역 함수는 정의되지 않는다 (오토로드 대상이 아니므로).
|
||||
* 또한 `Router::setCompiledRoutes()` 가 라우트 컬렉션을 통째로 교체하므로,
|
||||
* 벤더 프로바이더가 `boot()` 에서 등록한 라우트는 조건 충족 여부와 무관하게 폐기된다.
|
||||
*
|
||||
* 왜 서브프로세스인가:
|
||||
* 같은 프로세스 안에서는 재현 자체가 불가능하다 — 상세 근거는
|
||||
* `tests/Fixtures/DevTools/route-cache-harness.php` 헤더 주석 참조.
|
||||
*
|
||||
* 선례: tests/Feature/Upgrade/CoreUpgradeStepSpawnTest.php (PHP_BINARY + escapeshellarg + proc_open)
|
||||
*/
|
||||
class DevtoolsRouteCacheTest extends TestCase
|
||||
{
|
||||
/** 자식 프로세스가 사용할 캐시 임시 디렉토리 (base_path 상대 POSIX 경로) */
|
||||
private static ?string $cacheRelDir = null;
|
||||
|
||||
/** 클래스당 1회만 굽는다 (bake 는 수 초가 걸린다) */
|
||||
private static ?array $bakeResult = null;
|
||||
|
||||
/** 실 라우트 캐시의 최초 md5 (미존재 시 null) */
|
||||
private static ?string $realCacheDigest = null;
|
||||
|
||||
private static bool $realCacheExisted = false;
|
||||
|
||||
/**
|
||||
* 덤프 위임이 실제로 수행됐는지 확인할 때 쓰는 섹션.
|
||||
*
|
||||
* 하네스 자식 프로세스는 실 `storage/debug-dump` 에 쓴다 (경로가 `storage_path()` 고정).
|
||||
* PO 가 브라우저에서 받아 둔 덤프를 테스트가 덮어쓰지 않도록 이 파일만 스냅샷·복원한다.
|
||||
*/
|
||||
private const PROBE_SECTION = 'logs';
|
||||
|
||||
private const PROBE_FILE = 'logs-latest.json';
|
||||
|
||||
/** 덤프 파일의 최초 내용 (미존재 시 null) */
|
||||
private static ?string $probeBackup = null;
|
||||
|
||||
private static bool $probeExisted = false;
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
parent::setUpBeforeClass();
|
||||
|
||||
$repoRoot = dirname(__DIR__, 3);
|
||||
$testingDir = $repoRoot.'/storage/framework/testing';
|
||||
|
||||
// 이전 실행이 비정상 종료해 남긴 잔여물 정리 (1시간 이상 된 것만).
|
||||
if (is_dir($testingDir)) {
|
||||
foreach (glob($testingDir.'/route-cache-*') ?: [] as $stale) {
|
||||
if (is_dir($stale) && filemtime($stale) < time() - 3600) {
|
||||
self::removeDirectory($stale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::$cacheRelDir = 'storage/framework/testing/route-cache-'.uniqid();
|
||||
|
||||
$realCachePath = $repoRoot.'/bootstrap/cache/routes-v7.php';
|
||||
self::$realCacheExisted = file_exists($realCachePath);
|
||||
self::$realCacheDigest = self::$realCacheExisted ? md5_file($realCachePath) : null;
|
||||
|
||||
$probePath = $repoRoot.'/storage/debug-dump/'.self::PROBE_FILE;
|
||||
self::$probeExisted = file_exists($probePath);
|
||||
self::$probeBackup = self::$probeExisted ? file_get_contents($probePath) : null;
|
||||
}
|
||||
|
||||
public static function tearDownAfterClass(): void
|
||||
{
|
||||
// PHPUnit 은 테스트 실패·예외와 무관하게 이 훅을 호출한다.
|
||||
if (self::$cacheRelDir !== null) {
|
||||
self::removeDirectory(dirname(__DIR__, 3).'/'.self::$cacheRelDir);
|
||||
}
|
||||
|
||||
// PO 의 덤프 데이터를 원상 복구 (자식 프로세스가 실 storage 에 썼다).
|
||||
$probePath = dirname(__DIR__, 3).'/storage/debug-dump/'.self::PROBE_FILE;
|
||||
if (self::$probeExisted && self::$probeBackup !== null) {
|
||||
file_put_contents($probePath, self::$probeBackup);
|
||||
} elseif (! self::$probeExisted && file_exists($probePath)) {
|
||||
@unlink($probePath);
|
||||
}
|
||||
|
||||
self::$cacheRelDir = null;
|
||||
self::$bakeResult = null;
|
||||
self::$realCacheDigest = null;
|
||||
self::$probeBackup = null;
|
||||
|
||||
parent::tearDownAfterClass();
|
||||
}
|
||||
|
||||
/**
|
||||
* `dump-state` 는 라우트 캐시가 걸린 상태에서도 200 을 돌려주어야 한다.
|
||||
*
|
||||
* 수정 전: HTTP 500 `Call to undefined function handleLegacyDump()`
|
||||
*/
|
||||
public function test_dump_state_survives_route_cache(): void
|
||||
{
|
||||
$marker = 'harness-'.uniqid();
|
||||
|
||||
$result = $this->dispatch('POST', '/_boost/g7-debug/dump-state', json_encode([
|
||||
self::PROBE_SECTION => ['marker' => $marker],
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$this->assertTrue(
|
||||
$result['routesAreCached'],
|
||||
'라우트 캐시가 걸리지 않은 채 통과하면 이 테스트는 아무것도 검증하지 못합니다 (가짜 green).'
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
'route-cache-',
|
||||
$result['cachedRoutesPath'],
|
||||
'자식 프로세스가 임시 캐시가 아니라 실 캐시를 사용했습니다.'
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
200,
|
||||
$result['status'],
|
||||
"라우트 캐시 상태에서 dump-state 가 200 이어야 합니다.\nbody: {$result['body']}"
|
||||
);
|
||||
$this->assertStringNotContainsString('undefined function', $result['body']);
|
||||
|
||||
// 200 만으로는 부족하다 — 페이로드가 비어 있어도 legacy 경로는 200 을 돌려준다.
|
||||
// 위임(DebugDumpWriter)이 실제로 수행됐음을 파일로 확인한다.
|
||||
$probePath = base_path('storage/debug-dump/'.self::PROBE_FILE);
|
||||
|
||||
$this->assertFileExists($probePath, '덤프 위임이 파일을 쓰지 않았습니다.');
|
||||
$this->assertStringContainsString(
|
||||
$marker,
|
||||
(string) file_get_contents($probePath),
|
||||
'덤프 파일에 이번 요청의 페이로드가 담기지 않았습니다 (본문이 자식 프로세스에 전달되지 않음).'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `_boost/browser-logs` 는 라우트 캐시가 걸린 상태에서도 POST 를 받아야 한다.
|
||||
*
|
||||
* 수정 전: HTTP 405 — boost 프로바이더가 boot() 에서 등록한 라우트가 캐시 교체로 폐기되고,
|
||||
* `routes/web.php` 의 SPA catch-all(GET 전용)만 남아 POST 를 튕겨낸다.
|
||||
*/
|
||||
public function test_boost_browser_logs_survives_route_cache(): void
|
||||
{
|
||||
$result = $this->dispatch('POST', '/_boost/browser-logs', json_encode([
|
||||
'logs' => [[
|
||||
'type' => 'log',
|
||||
'timestamp' => '2026-01-01T00:00:00+00:00',
|
||||
'data' => ['harness'],
|
||||
'url' => 'https://example.test/harness',
|
||||
'userAgent' => 'harness',
|
||||
]],
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$this->assertTrue(
|
||||
$result['routesAreCached'],
|
||||
'라우트 캐시가 걸리지 않은 채 통과하면 이 테스트는 아무것도 검증하지 못합니다 (가짜 green).'
|
||||
);
|
||||
$this->assertStringContainsString('route-cache-', $result['cachedRoutesPath']);
|
||||
|
||||
$this->assertSame(
|
||||
200,
|
||||
$result['status'],
|
||||
"라우트 캐시 상태에서 browser-logs 가 200 이어야 합니다.\nbody: {$result['body']}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시된 클로저 안에서 `__()` 번역 헬퍼가 해석되어야 한다.
|
||||
*
|
||||
* 전역 함수와 달리 `__()` 는 Composer 의 `files` 오토로드로 등록되므로 라우트 파일 로드
|
||||
* 여부와 무관하다 — 다만 "라우트 파일에 있던 것은 캐시에서 사라진다" 는 이 이슈의 성질상
|
||||
* 실제로 해석되는지를 단언해 둔다. 미해석 시 Laravel 은 오류 없이 키 문자열을 그대로
|
||||
* 응답에 실으므로, 단언이 없으면 `devtools.connection_ok` 노출을 알아챌 방법이 없다.
|
||||
*/
|
||||
public function test_translation_helper_resolves_inside_cached_closure(): void
|
||||
{
|
||||
$result = $this->dispatch('POST', '/_boost/g7-debug/dump-state', json_encode([
|
||||
'test' => true,
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$this->assertTrue($result['routesAreCached']);
|
||||
$this->assertSame(200, $result['status'], "body: {$result['body']}");
|
||||
|
||||
$payload = json_decode($result['body'], true);
|
||||
|
||||
$this->assertIsArray($payload, "응답 JSON 파싱 실패.\nbody: {$result['body']}");
|
||||
$this->assertNotSame(
|
||||
'devtools.connection_ok',
|
||||
$payload['message'] ?? null,
|
||||
'캐시된 클로저 안에서 번역 키가 해석되지 않았습니다 (키 문자열이 그대로 노출됨).'
|
||||
);
|
||||
$this->assertNotEmpty($payload['message'] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 라우트 파일은 전역 함수를 선언하지 않아야 한다 (in-process, audit 룰과 이중화).
|
||||
*
|
||||
* 라우트 캐시가 걸리면 라우트 파일이 로드되지 않으므로, 파일 안에 정의된 전역 함수는
|
||||
* 존재하지 않는다. 핸들러가 그것을 호출하면 즉사한다.
|
||||
*/
|
||||
public function test_devtools_route_file_declares_no_global_functions(): void
|
||||
{
|
||||
$path = base_path('routes/devtools.php');
|
||||
$source = file_get_contents($path);
|
||||
|
||||
$this->assertIsString($source);
|
||||
|
||||
preg_match_all('/^\s*function\s+(\w+)\s*\(/m', $source, $matches);
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
$matches[1],
|
||||
'routes/devtools.php 에 전역 함수가 선언되어 있습니다: '.implode(', ', $matches[1])
|
||||
.'. 라우트 캐시가 걸리면 이 파일은 로드되지 않아 핸들러가 이 함수를 찾지 못합니다 '
|
||||
.'(→ Call to undefined function). 클래스로 옮겨 오토로드 대상으로 만드세요.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 자식 프로세스가 실 `bootstrap/cache/routes-v7.php` 를 건드리지 않아야 한다.
|
||||
*
|
||||
* 실 캐시가 testing 기준으로 재생성되면 개발 환경이 통째로 깨진다.
|
||||
*/
|
||||
public function test_real_bootstrap_cache_is_untouched(): void
|
||||
{
|
||||
// 앞선 테스트들이 자식 프로세스를 이미 띄웠음을 보장한다.
|
||||
$this->bakeOnce();
|
||||
|
||||
$realCachePath = base_path('bootstrap/cache/routes-v7.php');
|
||||
|
||||
$this->assertSame(
|
||||
self::$realCacheExisted,
|
||||
file_exists($realCachePath),
|
||||
'자식 프로세스가 실 라우트 캐시를 생성하거나 삭제했습니다.'
|
||||
);
|
||||
|
||||
if (self::$realCacheExisted) {
|
||||
$this->assertSame(
|
||||
self::$realCacheDigest,
|
||||
md5_file($realCachePath),
|
||||
'자식 프로세스가 실 라우트 캐시를 덮어썼습니다 (임시 캐시 경로 주입 실패).'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 경로에 라우트 캐시를 굽는다 (클래스당 1회).
|
||||
*
|
||||
* @return array<string, mixed> 하네스 bake 결과
|
||||
*/
|
||||
private function bakeOnce(): array
|
||||
{
|
||||
if (self::$bakeResult !== null) {
|
||||
return self::$bakeResult;
|
||||
}
|
||||
|
||||
[$exitCode, $output] = $this->runHarness(['bake', self::$cacheRelDir]);
|
||||
$payload = $this->parseHarnessOutput($output);
|
||||
|
||||
// bake 실패를 markTestSkipped 로 덮으면 회귀 방지가 통째로 무력화된다.
|
||||
$this->assertSame(
|
||||
0,
|
||||
$exitCode,
|
||||
"라우트 캐시 굽기(bake)가 실패했습니다.\noutput:\n{$output}"
|
||||
);
|
||||
$this->assertTrue(
|
||||
$payload['cacheWritten'] ?? false,
|
||||
"bake 후에도 라우트 캐시 파일이 생성되지 않았습니다.\noutput:\n{$output}"
|
||||
);
|
||||
|
||||
return self::$bakeResult = $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시된 라우트로 부팅한 자식 프로세스에 HTTP 요청을 디스패치한다.
|
||||
*
|
||||
* @param string $method HTTP 메서드
|
||||
* @param string $uri 요청 URI
|
||||
* @param string $body JSON 본문
|
||||
* @return array<string, mixed> 하네스 dispatch 결과
|
||||
*/
|
||||
private function dispatch(string $method, string $uri, string $body = ''): array
|
||||
{
|
||||
$this->bakeOnce();
|
||||
|
||||
// 본문은 base64 로 넘긴다 — Windows 의 escapeshellarg() 가 큰따옴표를 공백으로
|
||||
// 치환해 JSON 이 조용히 깨지기 때문 (하네스 헤더 주석 참조).
|
||||
[, $output] = $this->runHarness([
|
||||
'dispatch',
|
||||
self::$cacheRelDir,
|
||||
$method,
|
||||
$uri,
|
||||
$body === '' ? '' : base64_encode($body),
|
||||
]);
|
||||
|
||||
return $this->parseHarnessOutput($output);
|
||||
}
|
||||
|
||||
/**
|
||||
* 하네스 스크립트를 별도 PHP 프로세스로 실행한다.
|
||||
*
|
||||
* @param array<int, string> $args 하네스 인자
|
||||
* @return array{0: int, 1: string} [종료 코드, stdout+stderr]
|
||||
*/
|
||||
private function runHarness(array $args): array
|
||||
{
|
||||
if (! function_exists('proc_open')) {
|
||||
$this->markTestSkipped('proc_open 미지원 환경');
|
||||
}
|
||||
|
||||
$commandLine = implode(' ', array_map('escapeshellarg', array_merge([
|
||||
PHP_BINARY,
|
||||
base_path('tests/Fixtures/DevTools/route-cache-harness.php'),
|
||||
], $args))).' 2>&1';
|
||||
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
$process = proc_open($commandLine, $descriptors, $pipes, base_path());
|
||||
$this->assertIsResource($process, '하네스 프로세스 실행에 실패했습니다.');
|
||||
|
||||
fclose($pipes[0]);
|
||||
$stdout = stream_get_contents($pipes[1]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
return [$exitCode, (string) $stdout];
|
||||
}
|
||||
|
||||
/**
|
||||
* 하네스 출력에서 마커 뒤 JSON 을 추출한다.
|
||||
*
|
||||
* @param string $output 프로세스 출력 전문
|
||||
* @return array<string, mixed> 파싱된 페이로드
|
||||
*/
|
||||
private function parseHarnessOutput(string $output): array
|
||||
{
|
||||
$marker = '@@G7HARNESS@@';
|
||||
$pos = strrpos($output, $marker);
|
||||
|
||||
$this->assertNotFalse(
|
||||
$pos,
|
||||
"하네스 출력에서 결과 마커를 찾지 못했습니다 (자식 프로세스가 부팅 중 죽었을 가능성).\noutput:\n{$output}"
|
||||
);
|
||||
|
||||
$json = trim(substr($output, $pos + strlen($marker)));
|
||||
$decoded = json_decode($json, true);
|
||||
|
||||
$this->assertIsArray(
|
||||
$decoded,
|
||||
"하네스 결과 JSON 파싱에 실패했습니다.\noutput:\n{$output}"
|
||||
);
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 디렉토리를 재귀적으로 삭제한다 (Laravel 부트스트랩에 의존하지 않는 순수 구현).
|
||||
*
|
||||
* @param string $dir 삭제할 디렉토리 절대경로
|
||||
* @return void
|
||||
*/
|
||||
private static function removeDirectory(string $dir): void
|
||||
{
|
||||
if (! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (scandir($dir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $dir.'/'.$entry;
|
||||
is_dir($path) ? self::removeDirectory($path) : @unlink($path);
|
||||
}
|
||||
|
||||
@rmdir($dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
/**
|
||||
* 라우트 캐시 회귀 검증용 서브프로세스 하네스.
|
||||
*
|
||||
* 왜 서브프로세스인가:
|
||||
* 이 결함은 **같은 프로세스 안에서 재현되지 않는다**. `routes/devtools.php` 가 한 번이라도
|
||||
* 로드되면 그 안의 전역 함수는 PHP 프로세스 전역에 남고, `Artisan::call('route:cache')` 는
|
||||
* `getFreshApplication()` 으로 라우트 파일을 다시 로드해 오히려 함수를 정의해 버린다.
|
||||
* 게다가 `RouteCacheHelper::rebuild()` 는 testing 환경에서 캐시를 굽지 않는다.
|
||||
* → in-process 테스트는 수정 전에도 통과하는 가짜 green 이 된다.
|
||||
*
|
||||
* 사용법:
|
||||
* php tests/Fixtures/DevTools/route-cache-harness.php bake <cacheRelDir>
|
||||
* php tests/Fixtures/DevTools/route-cache-harness.php dispatch <cacheRelDir> <METHOD> <URI> [<base64Body>]
|
||||
*
|
||||
* 본문은 **base64 로 인코딩해서** 넘긴다. Windows 의 `escapeshellarg()` 는 인자 안의
|
||||
* 큰따옴표를 공백으로 치환하므로 JSON 을 그대로 넘기면 `{"test":true}` 가 `{ test :true}` 로
|
||||
* 깨져 도착한다. 파싱 오류도 나지 않고 그저 입력이 비어 있는 요청이 되므로, 200 만 단언하는
|
||||
* 테스트는 페이로드가 전달되지 않은 채로 통과한다 (실측으로 발견).
|
||||
*
|
||||
* 출력:
|
||||
* 마지막 줄에 `@@G7HARNESS@@` 마커 + JSON 1줄.
|
||||
* 부팅 중 deprecation 경고가 stdout 에 섞여도 파싱이 깨지지 않고, 실패 시 원문 전체를
|
||||
* assertion 메시지로 붙일 수 있다.
|
||||
* - bake → exit code 전파 (캐시 굽기 실패를 프로세스 실패로 드러낸다)
|
||||
* - dispatch → 항상 exit 0 (HTTP 500 과 프로세스 실패를 분리)
|
||||
*
|
||||
* 함정 1 — 캐시 경로는 base_path 상대 POSIX 경로만 받는다.
|
||||
* `Application::normalizeCachePath()`(vendor/laravel/framework/src/Illuminate/Foundation/Application.php:1379)
|
||||
* 는 `$absoluteCachePathPrefixes = ['/', '\\']`(:209) 로 시작하는 값만 절대경로로 취급한다.
|
||||
* Windows 의 `C:\...` 는 여기 걸리지 않아 `basePath($env)` 로 이어붙어 엉뚱한 위치를 가리킨다.
|
||||
*
|
||||
* 함정 2 — 환경변수는 프로세스 env 가 아니라 `$_SERVER`/`$_ENV` 에 직접 넣는다.
|
||||
* `bootstrap/app.php:62` 의 `Env::disablePutenv()` 때문에 리더가 `$_SERVER`/`$_ENV` 뿐이고,
|
||||
* CLI 의 `variables_order` 기본값 `"GPCS"` 에는 `E` 가 없어 `proc_open($env)` 이 조용히
|
||||
* 무시될 수 있다. 그러면 자식이 **실 캐시를 읽고 덮어쓴다**.
|
||||
*
|
||||
* 5개 캐시 경로를 전부 임시 디렉토리로 돌린다:
|
||||
* - APP_ROUTES_CACHE 검증 대상
|
||||
* - APP_CONFIG_CACHE 살아 있으면 자식이 개발 DB 로 부팅한다
|
||||
* - APP_PACKAGES_CACHE / APP_SERVICES_CACHE / APP_EVENTS_CACHE
|
||||
* 자식이 실 경로에 쓰는 것을 차단
|
||||
* (tests/Unit/Extension/ExtensionAutoloadCacheGuardTest.php 규율)
|
||||
*/
|
||||
const HARNESS_MARKER = '@@G7HARNESS@@';
|
||||
|
||||
$repoRoot = dirname(__DIR__, 3);
|
||||
|
||||
$argvLocal = $_SERVER['argv'] ?? [];
|
||||
$mode = $argvLocal[1] ?? '';
|
||||
$cacheRelDir = $argvLocal[2] ?? '';
|
||||
|
||||
if (! in_array($mode, ['bake', 'dispatch'], true) || $cacheRelDir === '') {
|
||||
fwrite(STDERR, "usage: route-cache-harness.php bake|dispatch <cacheRelDir> [...]\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
// 함정 1: 상대 POSIX 경로만 수용한다.
|
||||
if (preg_match('#^([A-Za-z]:|[/\\\\])#', $cacheRelDir) === 1 || str_contains($cacheRelDir, '\\')) {
|
||||
fwrite(STDERR, "cacheRelDir 는 base_path 상대 POSIX 경로여야 합니다: {$cacheRelDir}\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$cacheAbsDir = $repoRoot.'/'.$cacheRelDir;
|
||||
if (! is_dir($cacheAbsDir) && ! @mkdir($cacheAbsDir, 0755, true) && ! is_dir($cacheAbsDir)) {
|
||||
fwrite(STDERR, "캐시 임시 디렉토리 생성 실패: {$cacheAbsDir}\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
// 함정 2: $_SERVER/$_ENV 에 직접 주입 (Env::disablePutenv 대응).
|
||||
$envOverrides = [
|
||||
'APP_ENV' => 'testing',
|
||||
// 게이트 단축평가 통과 + 예외 본문을 JSON 으로 확보한다.
|
||||
'APP_DEBUG' => 'true',
|
||||
'APP_ROUTES_CACHE' => $cacheRelDir.'/routes-v7.php',
|
||||
'APP_CONFIG_CACHE' => $cacheRelDir.'/config.php',
|
||||
'APP_PACKAGES_CACHE' => $cacheRelDir.'/packages.php',
|
||||
'APP_SERVICES_CACHE' => $cacheRelDir.'/services.php',
|
||||
'APP_EVENTS_CACHE' => $cacheRelDir.'/events.php',
|
||||
];
|
||||
|
||||
foreach ($envOverrides as $key => $value) {
|
||||
$_SERVER[$key] = $value;
|
||||
$_ENV[$key] = $value;
|
||||
}
|
||||
|
||||
require $repoRoot.'/vendor/autoload.php';
|
||||
|
||||
/**
|
||||
* 결과 JSON 을 마커와 함께 출력한다.
|
||||
*
|
||||
* @param array<string, mixed> $payload 출력 페이로드
|
||||
* @return void
|
||||
*/
|
||||
function harnessEmit(array $payload): void
|
||||
{
|
||||
echo "\n".HARNESS_MARKER.json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)."\n";
|
||||
}
|
||||
|
||||
if ($mode === 'bake') {
|
||||
/** @var Application $app */
|
||||
$app = require $repoRoot.'/bootstrap/app.php';
|
||||
|
||||
/** @var Kernel $kernel */
|
||||
$kernel = $app->make(Kernel::class);
|
||||
$kernel->bootstrap();
|
||||
|
||||
$exitCode = 0;
|
||||
$error = null;
|
||||
|
||||
try {
|
||||
// `route:cache` 는 route:clear 후 fresh application 을 부팅해 라우트를 수집한다.
|
||||
// fresh application 도 같은 프로세스의 $_SERVER 를 보므로 캐시 경로는 임시 위치 그대로다.
|
||||
$exitCode = Artisan::call('route:cache');
|
||||
echo Artisan::output();
|
||||
} catch (Throwable $e) {
|
||||
$exitCode = 1;
|
||||
$error = get_class($e).': '.$e->getMessage();
|
||||
}
|
||||
|
||||
$cachedPath = $app->getCachedRoutesPath();
|
||||
|
||||
// 여기서 `$app->routesAreCached()` 를 쓰면 안 된다 — `Application::routesAreCached()`
|
||||
// (vendor Foundation/Application.php:1327) 는 결과를 컨테이너에 `routes.cached` 로
|
||||
// **메모이즈**한다. 부팅 시 `RouteServiceProvider::boot()` 이 이미 호출했고 그때는 캐시가
|
||||
// 없었으므로, 방금 1MB 짜리 캐시를 구웠어도 이 프로세스는 영원히 false 를 돌려준다.
|
||||
// bake 가 확인해야 하는 것은 "부팅이 캐시를 썼는가" 가 아니라 "파일이 생겼는가" 다.
|
||||
clearstatcache(true, $cachedPath);
|
||||
$cacheWritten = is_file($cachedPath);
|
||||
|
||||
harnessEmit([
|
||||
'mode' => 'bake',
|
||||
'exitCode' => $exitCode,
|
||||
'error' => $error,
|
||||
'cacheWritten' => $cacheWritten,
|
||||
'cacheBytes' => $cacheWritten ? filesize($cachedPath) : 0,
|
||||
'cachedRoutesPath' => str_replace('\\', '/', $cachedPath),
|
||||
]);
|
||||
|
||||
exit($exitCode === 0 && $cacheWritten ? 0 : 1);
|
||||
}
|
||||
|
||||
// ── dispatch ──────────────────────────────────────────────────────────────
|
||||
$method = strtoupper($argvLocal[3] ?? 'GET');
|
||||
$uri = $argvLocal[4] ?? '/';
|
||||
|
||||
$encodedBody = $argvLocal[5] ?? '';
|
||||
$body = '';
|
||||
|
||||
if ($encodedBody !== '') {
|
||||
$decoded = base64_decode($encodedBody, true);
|
||||
|
||||
if ($decoded === false) {
|
||||
fwrite(STDERR, "본문은 base64 로 인코딩해서 넘겨야 합니다.\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$body = $decoded;
|
||||
}
|
||||
|
||||
/** @var Application $app */
|
||||
$app = require $repoRoot.'/bootstrap/app.php';
|
||||
|
||||
/** @var Illuminate\Contracts\Http\Kernel $httpKernel */
|
||||
$httpKernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
|
||||
|
||||
$server = [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_ACCEPT' => 'application/json',
|
||||
];
|
||||
|
||||
$request = Request::create($uri, $method, [], [], [], $server, $body === '' ? null : $body);
|
||||
|
||||
$status = 0;
|
||||
$responseBody = '';
|
||||
$error = null;
|
||||
|
||||
try {
|
||||
$response = $httpKernel->handle($request);
|
||||
$status = $response->getStatusCode();
|
||||
$responseBody = (string) $response->getContent();
|
||||
$httpKernel->terminate($request, $response);
|
||||
} catch (Throwable $e) {
|
||||
$error = get_class($e).': '.$e->getMessage();
|
||||
}
|
||||
|
||||
harnessEmit([
|
||||
'mode' => 'dispatch',
|
||||
'method' => $method,
|
||||
'uri' => $uri,
|
||||
'routesAreCached' => $app->routesAreCached(),
|
||||
'cachedRoutesPath' => str_replace('\\', '/', $app->getCachedRoutesPath()),
|
||||
'status' => $status,
|
||||
'body' => mb_substr($responseBody, 0, 4000),
|
||||
'error' => $error,
|
||||
]);
|
||||
|
||||
exit(0);
|
||||
Reference in New Issue
Block a user