fix(installer): Composer 설치 로그 인코딩으로 인한 진행 상태 응답 빈 본문 보완
Composer 실행 모드로 설치할 때 일부 Windows 환경에서 composer 출력의 invalid UTF-8 바이트가 정규화 없이 로그에 저장되어, 진행 상태 응답의 json_encode 가 false 를 반환 → 빈 본문(HTTP 200) → 프론트 파싱 실패로 진행 상황이 표시되지 않던 문제. - 로그 저장(addLog)에서 mb_scrub 로 항상 유효 UTF-8 보장 (근본) - composer 스트림 라인 정규화 (SSE/폴링 공통 경계) - 폴링(state-management)·SSE(progress-emitter) 응답 json_encode 에 JSON_INVALID_UTF8_SUBSTITUTE + false 가드 (양쪽 안전망) 무관 결함 동시 정리: 신규 격리 테스트가 트리거한 BASE_PATH 누수로 깨지던 DeleteDirectoryTest·InstallerWindowsCommandsTest 를 프로젝트 루트 절대경로 앵커로 격리. 공개 이슈 gnuboard/g7 (@kitrio 제보).
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
### Fixed
|
||||
|
||||
- 코어 업데이트 시 레이아웃 편집기·개발자 진단도구 번들의 빌드 설정 파일이 갱신 대상에서 누락되던 문제를 수정했습니다. 이제 코어를 업데이트하면 이 파일들도 최신 내용으로 반영됩니다.
|
||||
- 설치 마법사에서 Vendor 설치 방식을 "Composer 실행"으로 선택해 설치할 때, 일부 Windows 환경에서 Composer 설치 로그의 문자 인코딩이 어긋나면 진행 상태 응답이 비어 진행 상황이 표시되지 않고 오류가 반복될 수 있던 문제를 보완했습니다. 어떤 문자가 섞이더라도 진행 로그가 항상 정상적으로 전달되도록 처리했습니다. (#62 @kitrio 님께서 제보해주셨습니다.)
|
||||
|
||||
## [7.0.1] - 2026-07-03
|
||||
|
||||
|
||||
@@ -138,7 +138,28 @@ class StateManagementApi
|
||||
];
|
||||
|
||||
// JSON 응답 반환
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
//
|
||||
// 최종 안전망 (gnuboard/g7#62): $response['logs'] 에 invalid UTF-8 바이트가
|
||||
// 섞이면 json_encode 가 false 를 반환하고 echo false = 빈 본문(HTTP 200) 이 되어
|
||||
// 프론트 폴링(res.json())이 "Unexpected end of JSON input" 으로 폭주한다.
|
||||
// 로그는 addLog / task-runner 에서 이미 scrub 되지만, 어떤 경로로도 응답이
|
||||
// 빈 본문이 되지 않도록 JSON_INVALID_UTF8_SUBSTITUTE 로 치환하고 false 를 가드한다.
|
||||
$encoded = json_encode(
|
||||
$response,
|
||||
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE
|
||||
);
|
||||
|
||||
if ($encoded === false) {
|
||||
// substitute 플래그로도 인코딩 실패한 극단적 케이스 — 폴링이 파싱 가능한
|
||||
// 최소 유효 JSON 을 반환해 프론트가 다음 tick 에서 정상 복구되도록 한다.
|
||||
$encoded = json_encode([
|
||||
'status' => $status,
|
||||
'logs' => [],
|
||||
'log_total' => $logTotal,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
}
|
||||
|
||||
echo $encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,16 +5,15 @@
|
||||
*
|
||||
* 설치 진행 상태를 storage/installer-state.json에 저장하고 조회합니다.
|
||||
*/
|
||||
|
||||
if (!defined('BASE_PATH')) {
|
||||
if (! defined('BASE_PATH')) {
|
||||
define('BASE_PATH', realpath(dirname(__DIR__, 3)) ?: dirname(__DIR__, 3)); // public/install/includes에서 프로젝트 루트로
|
||||
}
|
||||
|
||||
if (! defined('STATE_PATH')) {
|
||||
define('STATE_PATH', BASE_PATH . '/storage/installer-state.json');
|
||||
define('STATE_PATH', BASE_PATH.'/storage/installer-state.json');
|
||||
}
|
||||
if (! defined('INSTALLER_DIR')) {
|
||||
define('INSTALLER_DIR', BASE_PATH . '/storage/installer');
|
||||
define('INSTALLER_DIR', BASE_PATH.'/storage/installer');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,15 +35,16 @@ function getInstallationState(): array
|
||||
// state.json 파일이 없으면 기본 상태 반환
|
||||
// (설치 완료 후 워커가 DELETE_INSTALLER_AFTER_COMPLETE로 정상 삭제하는 경우가 있으므로
|
||||
// 부재 자체는 에러가 아님. 호출자가 g7_installed 플래그 등으로 완료 여부 판정)
|
||||
if (!file_exists(STATE_PATH)) {
|
||||
if (! file_exists(STATE_PATH)) {
|
||||
return $defaultState;
|
||||
}
|
||||
|
||||
// 파일 읽기 권한 체크
|
||||
if (!is_readable(STATE_PATH)) {
|
||||
$msg = "[installer-state] State file is not readable: " . STATE_PATH;
|
||||
if (! is_readable(STATE_PATH)) {
|
||||
$msg = '[installer-state] State file is not readable: '.STATE_PATH;
|
||||
error_log($msg);
|
||||
addLog($msg);
|
||||
|
||||
return $defaultState;
|
||||
}
|
||||
|
||||
@@ -53,9 +53,10 @@ function getInstallationState(): array
|
||||
|
||||
// 파일 읽기 실패 시 기본 상태 반환
|
||||
if ($content === false) {
|
||||
$msg = "[installer-state] Failed to read state file: " . STATE_PATH;
|
||||
$msg = '[installer-state] Failed to read state file: '.STATE_PATH;
|
||||
error_log($msg);
|
||||
addLog($msg);
|
||||
|
||||
return $defaultState;
|
||||
}
|
||||
|
||||
@@ -65,9 +66,10 @@ function getInstallationState(): array
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$contentLen = strlen($content);
|
||||
$preview = substr($content, 0, 200);
|
||||
$msg = "[installer-state] Failed to parse state file JSON (length={$contentLen}): " . json_last_error_msg() . " / preview: " . $preview;
|
||||
$msg = "[installer-state] Failed to parse state file JSON (length={$contentLen}): ".json_last_error_msg().' / preview: '.$preview;
|
||||
error_log($msg);
|
||||
addLog($msg);
|
||||
|
||||
return $defaultState;
|
||||
}
|
||||
|
||||
@@ -77,25 +79,27 @@ function getInstallationState(): array
|
||||
/**
|
||||
* 설치 상태 저장
|
||||
*
|
||||
* @param array $state 저장할 상태 배열
|
||||
* @param array $state 저장할 상태 배열
|
||||
* @return bool 저장 성공 여부
|
||||
*/
|
||||
function saveInstallationState(array $state): bool
|
||||
{
|
||||
// storage 디렉토리 존재 여부 확인 (생성하지 않음)
|
||||
$storageDir = BASE_PATH . '/storage';
|
||||
if (!is_dir($storageDir)) {
|
||||
$storageDir = BASE_PATH.'/storage';
|
||||
if (! is_dir($storageDir)) {
|
||||
$msg = "[installer-state] Storage directory does not exist: {$storageDir}";
|
||||
error_log($msg);
|
||||
addLog($msg);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 디렉토리 쓰기 권한 확인
|
||||
if (!is_writable($storageDir)) {
|
||||
if (! is_writable($storageDir)) {
|
||||
$msg = "[installer-state] Storage directory is not writable: {$storageDir}";
|
||||
error_log($msg);
|
||||
addLog($msg);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -106,33 +110,36 @@ function saveInstallationState(array $state): bool
|
||||
$content = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($content === false) {
|
||||
$msg = "[installer-state] Failed to encode state as JSON: " . json_last_error_msg();
|
||||
$msg = '[installer-state] Failed to encode state as JSON: '.json_last_error_msg();
|
||||
error_log($msg);
|
||||
addLog($msg);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 원자적 쓰기: tmp 파일에 쓰고 rename으로 교체
|
||||
// 동시 쓰기 경쟁 상황에서 부분 쓰기(half-written file)로 인한 JSON 손상 방지.
|
||||
// Linux에서 rename()은 atomic syscall이므로 다른 리더가 항상 완전한 파일만 읽는다.
|
||||
$tmpPath = STATE_PATH . '.tmp.' . getmypid() . '.' . uniqid();
|
||||
$tmpPath = STATE_PATH.'.tmp.'.getmypid().'.'.uniqid();
|
||||
|
||||
$result = @file_put_contents($tmpPath, $content, LOCK_EX);
|
||||
if ($result === false || $result !== strlen($content)) {
|
||||
$msg = "[installer-state] Failed to write state tmp file: " . $tmpPath;
|
||||
$msg = '[installer-state] Failed to write state tmp file: '.$tmpPath;
|
||||
error_log($msg);
|
||||
addLog($msg);
|
||||
@unlink($tmpPath);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@chmod($tmpPath, 0664);
|
||||
|
||||
if (!@rename($tmpPath, STATE_PATH)) {
|
||||
$msg = "[installer-state] Failed to rename state tmp file: " . $tmpPath . ' → ' . STATE_PATH;
|
||||
if (! @rename($tmpPath, STATE_PATH)) {
|
||||
$msg = '[installer-state] Failed to rename state tmp file: '.$tmpPath.' → '.STATE_PATH;
|
||||
error_log($msg);
|
||||
addLog($msg);
|
||||
@unlink($tmpPath);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -161,13 +168,13 @@ function isInstallationCompleted(): bool
|
||||
}
|
||||
|
||||
// g7_installed 파일 존재 여부 확인 (추가 안전장치)
|
||||
$installedFlagPath = BASE_PATH . '/storage/app/g7_installed';
|
||||
$installedFlagPath = BASE_PATH.'/storage/app/g7_installed';
|
||||
if (file_exists($installedFlagPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// .env 파일의 INSTALLER_COMPLETED 플래그 확인
|
||||
$envPath = BASE_PATH . '/.env';
|
||||
$envPath = BASE_PATH.'/.env';
|
||||
if (file_exists($envPath)) {
|
||||
$envContent = file_get_contents($envPath);
|
||||
if (strpos($envContent, 'INSTALLER_COMPLETED=true') !== false) {
|
||||
@@ -181,7 +188,7 @@ function isInstallationCompleted(): bool
|
||||
/**
|
||||
* 특정 작업을 완료로 표시
|
||||
*
|
||||
* @param string $task 작업 식별자
|
||||
* @param string $task 작업 식별자
|
||||
* @return bool 업데이트 성공 여부
|
||||
*/
|
||||
function markTaskCompleted(string $task): bool
|
||||
@@ -189,7 +196,7 @@ function markTaskCompleted(string $task): bool
|
||||
$state = getInstallationState();
|
||||
|
||||
// completed_tasks 배열에 추가 (중복 방지)
|
||||
if (!in_array($task, $state['completed_tasks'])) {
|
||||
if (! in_array($task, $state['completed_tasks'])) {
|
||||
$state['completed_tasks'][] = $task;
|
||||
}
|
||||
|
||||
@@ -205,7 +212,7 @@ function markTaskCompleted(string $task): bool
|
||||
/**
|
||||
* 특정 작업을 완료 목록에서 제거
|
||||
*
|
||||
* @param string $task 작업 식별자
|
||||
* @param string $task 작업 식별자
|
||||
* @return bool 업데이트 성공 여부
|
||||
*/
|
||||
function removeTaskCompleted(string $task): bool
|
||||
@@ -213,7 +220,7 @@ function removeTaskCompleted(string $task): bool
|
||||
$state = getInstallationState();
|
||||
|
||||
$state['completed_tasks'] = array_values(
|
||||
array_filter($state['completed_tasks'], fn($t) => $t !== $task)
|
||||
array_filter($state['completed_tasks'], fn ($t) => $t !== $task)
|
||||
);
|
||||
|
||||
return saveInstallationState($state);
|
||||
@@ -251,7 +258,7 @@ function removeTaskCompleted(string $task): bool
|
||||
*
|
||||
* @param int $staleSeconds heartbeat 가 이 시간 이상 갱신 안 되면 stale 판정 (default 15초)
|
||||
* @return array{acquired: bool, worker_id: string|null, reason: string}
|
||||
* reason: 'available' | 'takeover_stale' | 'busy'
|
||||
* reason: 'available' | 'takeover_stale' | 'busy'
|
||||
*/
|
||||
function acquireWorkerLock(int $staleSeconds = 15): array
|
||||
{
|
||||
@@ -337,7 +344,7 @@ function applyExistingDbActionStateGuard(array $state, string $newAction): array
|
||||
$resetTasks = ['db_cleanup', 'db_migrate', 'db_seed'];
|
||||
$state['completed_tasks'] = array_values(array_filter(
|
||||
$state['completed_tasks'] ?? [],
|
||||
fn($t) => !in_array($t, $resetTasks, true)
|
||||
fn ($t) => ! in_array($t, $resetTasks, true)
|
||||
));
|
||||
} else {
|
||||
// 키 정규화 — completed_tasks 가 부재인 경우 빈 배열 보장
|
||||
@@ -350,7 +357,7 @@ function applyExistingDbActionStateGuard(array $state, string $newAction): array
|
||||
/**
|
||||
* 현재 진행 중인 작업 업데이트
|
||||
*
|
||||
* @param string $task 작업 식별자
|
||||
* @param string $task 작업 식별자
|
||||
* @return bool 업데이트 성공 여부
|
||||
*/
|
||||
function updateCurrentTask(string $task): bool
|
||||
@@ -369,7 +376,7 @@ function updateCurrentTask(string $task): bool
|
||||
* 페이지 새로고침/재시작 시 로그가 즉시 표시되도록
|
||||
* fflush() + clearstatcache()를 적용합니다.
|
||||
*
|
||||
* @param string $message 로그 메시지
|
||||
* @param string $message 로그 메시지
|
||||
* @return bool 저장 성공 여부
|
||||
*/
|
||||
/**
|
||||
@@ -389,23 +396,25 @@ function addLogBatch(array $messages): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
$logDir = BASE_PATH . '/storage/logs';
|
||||
$logFile = $logDir . '/installation.log';
|
||||
$logDir = BASE_PATH.'/storage/logs';
|
||||
$logFile = $logDir.'/installation.log';
|
||||
|
||||
if (!is_dir($logDir)) {
|
||||
if (! is_dir($logDir)) {
|
||||
$created = @mkdir($logDir, 0775, true);
|
||||
if (!$created) {
|
||||
if (! $created) {
|
||||
$msg = "[addLogBatch] Failed to create log directory: {$logDir}";
|
||||
error_log($msg);
|
||||
addLog($msg); // 재귀 가드가 차단하므로 안전 — error_log fallback 만 수행
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_writable($logDir)) {
|
||||
if (! is_writable($logDir)) {
|
||||
$msg = "[addLogBatch] Log directory is not writable: {$logDir}";
|
||||
error_log($msg);
|
||||
addLog($msg); // 재귀 가드가 차단
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -425,9 +434,9 @@ function addLogBatch(array $messages): bool
|
||||
}
|
||||
|
||||
clearstatcache(true, $logFile);
|
||||
$isNewFile = !file_exists($logFile) || filesize($logFile) === 0;
|
||||
$isNewFile = ! file_exists($logFile) || filesize($logFile) === 0;
|
||||
if ($isNewFile) {
|
||||
$entries = "\xEF\xBB\xBF" . $entries;
|
||||
$entries = "\xEF\xBB\xBF".$entries;
|
||||
}
|
||||
|
||||
$handle = @fopen($logFile, 'a');
|
||||
@@ -435,6 +444,7 @@ function addLogBatch(array $messages): bool
|
||||
$msg = "[addLogBatch] Failed to open log file: {$logFile}";
|
||||
error_log($msg);
|
||||
addLog($msg); // 재귀 가드가 차단
|
||||
|
||||
return false;
|
||||
}
|
||||
flock($handle, LOCK_EX);
|
||||
@@ -455,7 +465,8 @@ function addLog(string $message): bool
|
||||
static $inAddLog = false;
|
||||
if ($inAddLog) {
|
||||
// 재진입: installation.log 시도 skip, PHP error_log 만 호출하고 즉시 종료
|
||||
error_log("[addLog reentrant] " . $message);
|
||||
error_log('[addLog reentrant] '.$message);
|
||||
|
||||
return false;
|
||||
}
|
||||
$inAddLog = true;
|
||||
@@ -478,25 +489,27 @@ function addLog(string $message): bool
|
||||
*/
|
||||
function _addLogInternal(string $message): bool
|
||||
{
|
||||
$logDir = BASE_PATH . '/storage/logs';
|
||||
$logFile = $logDir . '/installation.log';
|
||||
$logDir = BASE_PATH.'/storage/logs';
|
||||
$logFile = $logDir.'/installation.log';
|
||||
|
||||
// 로그 디렉토리 확인 및 생성 시도
|
||||
if (!is_dir($logDir)) {
|
||||
if (! is_dir($logDir)) {
|
||||
$created = @mkdir($logDir, 0775, true);
|
||||
if (!$created) {
|
||||
if (! $created) {
|
||||
$msg = "[addLog] Failed to create log directory: {$logDir} (storage 권한 확인 필요)";
|
||||
error_log($msg);
|
||||
addLog($msg); // 재귀 가드가 차단 (본 함수는 이미 가드 안)
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 로그 디렉토리 쓰기 권한 확인
|
||||
if (!is_writable($logDir)) {
|
||||
if (! is_writable($logDir)) {
|
||||
$msg = "[addLog] Log directory is not writable: {$logDir}";
|
||||
error_log($msg);
|
||||
addLog($msg); // 재귀 가드가 차단
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -508,6 +521,17 @@ function _addLogInternal(string $message): bool
|
||||
}
|
||||
}
|
||||
|
||||
// OS 무관 최종 방어 — 항상 유효 UTF-8 만 로그에 저장한다 (gnuboard/g7#62).
|
||||
// composer install 등 외부 프로세스 stdout 이 시스템 코드페이지로 출력되거나
|
||||
// 진행바(\r 갱신)가 임의 바이트 경계에서 잘려 mb_detect_encoding 이 확정 감지에
|
||||
// 실패한 경우(위 분기 미적용), invalid 바이트가 그대로 로그에 남으면
|
||||
// 폴링 응답 state-management.php 의 json_encode 가 false 를 반환해 빈 본문(HTTP 200)
|
||||
// → 프론트 res.json() 이 "Unexpected end of JSON input" 으로 폭주한다.
|
||||
// mb_scrub 은 invalid 바이트를 U+FFFD 로 치환해 응답 무력화를 원천 차단한다.
|
||||
if (! mb_check_encoding($message, 'UTF-8')) {
|
||||
$message = mb_scrub($message, 'UTF-8');
|
||||
}
|
||||
|
||||
// 타임스탬프와 함께 로그 작성 — millisecond 정밀도 (hang 진단 시 정확한 timing 필요)
|
||||
$microtime = microtime(true);
|
||||
$ms = sprintf('%03d', (int) (($microtime - floor($microtime)) * 1000));
|
||||
@@ -518,10 +542,10 @@ function _addLogInternal(string $message): bool
|
||||
clearstatcache(true, $logFile);
|
||||
|
||||
// 파일이 없거나 빈 파일이면 UTF-8 BOM 추가 (Windows 텍스트 편집기 호환성)
|
||||
$isNewFile = !file_exists($logFile) || filesize($logFile) === 0;
|
||||
$isNewFile = ! file_exists($logFile) || filesize($logFile) === 0;
|
||||
if ($isNewFile) {
|
||||
$utf8Bom = "\xEF\xBB\xBF";
|
||||
$logEntry = $utf8Bom . $logEntry;
|
||||
$logEntry = $utf8Bom.$logEntry;
|
||||
}
|
||||
|
||||
// 파일 핸들 열기 (append 모드)
|
||||
@@ -530,6 +554,7 @@ function _addLogInternal(string $message): bool
|
||||
$msg = "[addLog] Failed to open log file: {$logFile}";
|
||||
error_log($msg);
|
||||
addLog($msg); // 재귀 가드가 차단
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -553,6 +578,7 @@ function _addLogInternal(string $message): bool
|
||||
$msg = "[addLog] Failed to write log file: {$logFile}";
|
||||
error_log($msg);
|
||||
addLog($msg); // 재귀 가드가 차단
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -565,16 +591,16 @@ function _addLogInternal(string $message): bool
|
||||
* 페이지 새로고침 시 최신 로그를 즉시 표시하기 위해
|
||||
* clearstatcache()를 적용합니다.
|
||||
*
|
||||
* @param int $offset 건너뛸 로그 줄 수 (폴링 모드 증분 조회용, 기본 0)
|
||||
* @param int $offset 건너뛸 로그 줄 수 (폴링 모드 증분 조회용, 기본 0)
|
||||
* @return array 로그 배열 [{timestamp, message}, ...]
|
||||
*/
|
||||
function getInstallationLogs(int $offset = 0): array
|
||||
{
|
||||
$logFile = BASE_PATH . '/storage/logs/installation.log';
|
||||
$logFile = BASE_PATH.'/storage/logs/installation.log';
|
||||
|
||||
clearstatcache(true, $logFile);
|
||||
|
||||
if (!file_exists($logFile)) {
|
||||
if (! file_exists($logFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -621,11 +647,11 @@ function getInstallationLogs(int $offset = 0): array
|
||||
*/
|
||||
function getInstallationLogCount(): int
|
||||
{
|
||||
$logFile = BASE_PATH . '/storage/logs/installation.log';
|
||||
$logFile = BASE_PATH.'/storage/logs/installation.log';
|
||||
|
||||
clearstatcache(true, $logFile);
|
||||
|
||||
if (!file_exists($logFile)) {
|
||||
if (! file_exists($logFile)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -637,10 +663,11 @@ function getInstallationLogCount(): int
|
||||
$lines = explode("\n", trim($content));
|
||||
$count = 0;
|
||||
foreach ($lines as $line) {
|
||||
if (!empty($line)) {
|
||||
if (! empty($line)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
@@ -655,7 +682,7 @@ function getLastCompletedStep(): int
|
||||
|
||||
// step_status를 역순으로 확인
|
||||
for ($step = 5; $step >= 0; $step--) {
|
||||
$stepKey = (string)$step;
|
||||
$stepKey = (string) $step;
|
||||
if (isset($state['step_status'][$stepKey]) && $state['step_status'][$stepKey] === 'completed') {
|
||||
return $step;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,8 @@
|
||||
* SSE/폴링 듀얼 모드 지원을 위한 진행 상황 보고 인터페이스.
|
||||
* - SseEmitter: 실시간 SSE 스트리밍 + addLog
|
||||
* - NullEmitter: addLog만 수행 (폴링 모드, state.json 기반 조회)
|
||||
*
|
||||
* @package G7\Installer
|
||||
*/
|
||||
|
||||
if (!defined('BASE_PATH')) {
|
||||
if (! defined('BASE_PATH')) {
|
||||
define('BASE_PATH', realpath(dirname(__DIR__, 3)) ?: dirname(__DIR__, 3));
|
||||
}
|
||||
|
||||
@@ -19,8 +16,8 @@ interface ProgressEmitter
|
||||
/**
|
||||
* 임의 이벤트 송출 (SSE 이벤트 타입과 데이터 배열).
|
||||
*
|
||||
* @param string $event 이벤트 타입 (connected, task_start, task_complete, log, completed, error, aborted, rollback_failed)
|
||||
* @param array $data 이벤트 데이터
|
||||
* @param string $event 이벤트 타입 (connected, task_start, task_complete, log, completed, error, aborted, rollback_failed)
|
||||
* @param array $data 이벤트 데이터
|
||||
*/
|
||||
public function emit(string $event, array $data): void;
|
||||
|
||||
@@ -57,8 +54,22 @@ class SseEmitter implements ProgressEmitter
|
||||
|
||||
public function emit(string $event, array $data): void
|
||||
{
|
||||
// SSE data 라인은 항상 유효 JSON 이어야 한다 (gnuboard/g7#62).
|
||||
// composer install 등 외부 프로세스 로그에 invalid UTF-8 바이트가 섞이면
|
||||
// json_encode 가 false 를 반환해 "data: \n\n" (빈 값) 이 송출되고,
|
||||
// 프론트 EventSource 리스너의 JSON.parse(e.data) 가 throw 되어 로그 이벤트가 유실된다.
|
||||
// 폴링 응답(state-management)과 동일하게 JSON_INVALID_UTF8_SUBSTITUTE 로 치환하고
|
||||
// false 를 가드한다. (task-runner 의 스트림 scrub 과 이중 안전망)
|
||||
$payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
if ($payload === false) {
|
||||
$payload = json_encode(
|
||||
['message' => '(log line dropped: encoding error)'],
|
||||
JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
}
|
||||
|
||||
echo "event: {$event}\n";
|
||||
echo "data: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
|
||||
echo 'data: '.$payload."\n\n";
|
||||
if (ob_get_level()) {
|
||||
@ob_flush();
|
||||
}
|
||||
@@ -195,9 +206,10 @@ class NullEmitter implements ProgressEmitter
|
||||
*/
|
||||
function getProgressEmitter(): ProgressEmitter
|
||||
{
|
||||
if (!isset($GLOBALS['g7_progress_emitter']) || !($GLOBALS['g7_progress_emitter'] instanceof ProgressEmitter)) {
|
||||
$GLOBALS['g7_progress_emitter'] = new SseEmitter();
|
||||
if (! isset($GLOBALS['g7_progress_emitter']) || ! ($GLOBALS['g7_progress_emitter'] instanceof ProgressEmitter)) {
|
||||
$GLOBALS['g7_progress_emitter'] = new SseEmitter;
|
||||
}
|
||||
|
||||
return $GLOBALS['g7_progress_emitter'];
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Installer;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\PreserveGlobalState;
|
||||
use PHPUnit\Framework\Attributes\RunClassInSeparateProcess;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* 설치 로그 기록 시 UTF-8 정규화(scrub) 회귀 테스트.
|
||||
*
|
||||
* 배경 (gnuboard/g7#62, dev-g7#445):
|
||||
* Vendor 설치 방식 "Composer 실행" + 폴링 모드 조합에서 브라우저 콘솔에
|
||||
* "Unexpected end of JSON input" 이 폭주하고 진행 상황이 표시되지 않는 회귀.
|
||||
*
|
||||
* 근본 원인: 폴링 워커가 composer install stdout 을 fgets() 로 읽어 정규화 없이
|
||||
* addLog() 로 넘긴다. Windows 코드페이지(CP949) 출력이나 다운로드 진행바(\r 갱신)가
|
||||
* 임의 바이트 경계에서 잘리면 invalid UTF-8 바이트가 생기는데,
|
||||
* _addLogInternal() 은 mb_detect_encoding(strict=true) 감지 실패 시 raw 바이트를
|
||||
* 그대로 로그에 저장한다. 그 로그를 포함한 폴링 응답 state-management.php 의
|
||||
* json_encode() 가 invalid UTF-8 때문에 false 를 반환 → echo false = 빈 본문(HTTP 200)
|
||||
* → 프론트 res.json() 이 "Unexpected end of JSON input" 실패.
|
||||
*
|
||||
* 본 테스트는 근본 지점을 검증한다: addLog() 에 invalid UTF-8 바이트를 주입해도
|
||||
* getInstallationLogs() 로 읽은 로그 메시지가 항상 유효 UTF-8 이어서
|
||||
* json_encode() 가 false 가 아니어야 한다.
|
||||
*
|
||||
* BASE_PATH 는 PHP 상수로 클래스 라이프사이클 단위 단 한 번 정의 (setUpBeforeClass).
|
||||
* InstallerStateSchemaTest 와 동일한 temp 격리 패턴을 사용한다.
|
||||
*/
|
||||
#[RunClassInSeparateProcess]
|
||||
#[PreserveGlobalState(false)]
|
||||
class AddLogUtf8ScrubTest extends TestCase
|
||||
{
|
||||
private static string $sharedBase = '';
|
||||
|
||||
private static string $skipReason = '';
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
parent::setUpBeforeClass();
|
||||
|
||||
// 안전 가드: BASE_PATH 가 시스템 temp 하위가 아니면 (= 다른 테스트가 프로젝트 루트로 박은 경우)
|
||||
// setUp 의 installation.log @unlink 가 실제 설치 로그를 파괴할 수 있으므로 skip.
|
||||
$tempPrefix = realpath(sys_get_temp_dir()) ?: sys_get_temp_dir();
|
||||
if (defined('BASE_PATH')) {
|
||||
$resolved = realpath((string) BASE_PATH) ?: (string) BASE_PATH;
|
||||
if (strpos($resolved, $tempPrefix) !== 0) {
|
||||
self::$skipReason = 'BASE_PATH ('.$resolved.') 가 시스템 temp 하위가 아님 — '.
|
||||
'다른 Installer 테스트의 BASE_PATH 정의가 선행됨. 격리 실행 필요: '.
|
||||
'php vendor/bin/phpunit --filter=AddLogUtf8ScrubTest';
|
||||
|
||||
return;
|
||||
}
|
||||
self::$sharedBase = (string) BASE_PATH;
|
||||
} else {
|
||||
self::$sharedBase = sys_get_temp_dir().'/g7-installer-addlog-test-'.bin2hex(random_bytes(4));
|
||||
define('BASE_PATH', self::$sharedBase);
|
||||
}
|
||||
|
||||
$logDir = self::$sharedBase.'/storage/logs';
|
||||
if (! is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
// config.php 의 INSTALLER_BASE_URL 계산이 $_SERVER['SCRIPT_NAME'] 에 의존
|
||||
if (! isset($_SERVER['SCRIPT_NAME'])) {
|
||||
$_SERVER['SCRIPT_NAME'] = '/install/index.php';
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__, 3).'/public/install/includes/config.php';
|
||||
require_once dirname(__DIR__, 3).'/public/install/includes/installer-state.php';
|
||||
require_once dirname(__DIR__, 3).'/public/install/includes/progress-emitter.php';
|
||||
}
|
||||
|
||||
public static function tearDownAfterClass(): void
|
||||
{
|
||||
if (self::$skipReason === '' && self::$sharedBase !== '') {
|
||||
$logFile = self::$sharedBase.'/storage/logs/installation.log';
|
||||
if (file_exists($logFile)) {
|
||||
@unlink($logFile);
|
||||
}
|
||||
}
|
||||
parent::tearDownAfterClass();
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
if (self::$skipReason !== '') {
|
||||
$this->markTestSkipped(self::$skipReason);
|
||||
}
|
||||
|
||||
// 각 테스트 시작 시 로그 초기화
|
||||
$logFile = self::$sharedBase.'/storage/logs/installation.log';
|
||||
if (file_exists($logFile)) {
|
||||
@unlink($logFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* invalid UTF-8 바이트 표본.
|
||||
*
|
||||
* - 잘린 한글 선행 바이트 (EC 99 은 3바이트 UTF-8 의 앞 2바이트, 마지막 바이트 누락)
|
||||
* - 단독 0xFF (UTF-8 에서 절대 유효하지 않은 바이트)
|
||||
* - CP949 로 인코딩된 한글이 감지 실패하는 케이스 모사
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function invalidUtf8Provider(): array
|
||||
{
|
||||
return [
|
||||
'truncated hangul lead bytes' => ["Downloading \xEC\x99 package"],
|
||||
'lone 0xFF byte' => ["progress: abc\xFFdef 100%"],
|
||||
'truncated 4-byte sequence' => ["emoji \xF0\x9F head"], // 4바이트 UTF-8 의 앞 2바이트만
|
||||
'lone continuation byte' => ["tail\x80end"],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('invalidUtf8Provider')]
|
||||
public function addlog_stores_valid_utf8_even_for_invalid_input(string $rawMessage): void
|
||||
{
|
||||
// 사전 조건: 입력은 실제로 invalid UTF-8 이어야 테스트가 의미 있음
|
||||
$this->assertFalse(
|
||||
mb_check_encoding($rawMessage, 'UTF-8'),
|
||||
'테스트 입력 자체가 invalid UTF-8 이어야 한다 (표본 점검).'
|
||||
);
|
||||
|
||||
$written = addLog($rawMessage);
|
||||
$this->assertTrue($written, 'addLog 는 성공적으로 로그를 기록해야 한다.');
|
||||
|
||||
$logs = getInstallationLogs();
|
||||
$this->assertNotEmpty($logs, '기록한 로그가 조회되어야 한다.');
|
||||
|
||||
$message = $logs[count($logs) - 1]['message'] ?? '';
|
||||
|
||||
// 핵심 계약: 저장된 로그 메시지는 항상 유효 UTF-8 이어야 한다.
|
||||
$this->assertTrue(
|
||||
mb_check_encoding($message, 'UTF-8'),
|
||||
'저장된 로그 메시지는 항상 유효 UTF-8 이어야 한다 (invalid 바이트는 scrub 대체). '.
|
||||
'실패 시: composer 출력의 깨진 바이트가 로그에 raw 저장되어 폴링 응답 json_encode 가 무력화됨.'
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('invalidUtf8Provider')]
|
||||
public function polling_response_json_encode_never_returns_false(string $rawMessage): void
|
||||
{
|
||||
addLog($rawMessage);
|
||||
|
||||
$logs = getInstallationLogs();
|
||||
|
||||
// 폴링 응답(state-management.php getState) 이 만드는 것과 동일한 구조를 모사.
|
||||
$response = [
|
||||
'status' => 'running',
|
||||
'logs' => $logs,
|
||||
'log_total' => getInstallationLogCount(),
|
||||
];
|
||||
|
||||
// 근본 수정 후: logs 가 유효 UTF-8 이므로 substitute 플래그 없이도 false 가 아니어야 한다.
|
||||
$encoded = json_encode($response, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
|
||||
$this->assertNotFalse(
|
||||
$encoded,
|
||||
'invalid UTF-8 로그가 섞여도 폴링 응답 json_encode 는 false 를 반환하면 안 된다 '.
|
||||
'(false → echo 빈 본문 → 프론트 "Unexpected end of JSON input").'
|
||||
);
|
||||
$this->assertIsString($encoded);
|
||||
$this->assertNotSame('', $encoded, '인코딩 결과 본문이 비어 있으면 안 된다.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function addlog_preserves_valid_utf8_korean_unchanged(): void
|
||||
{
|
||||
// 정규화가 정상 UTF-8 한글을 훼손하지 않아야 한다 (회귀 방지).
|
||||
// 첫 로그 줄은 UTF-8 BOM 이 붙어 getInstallationLogs 의 [timestamp] 파싱에 간섭하므로
|
||||
// 워밍업 로그를 먼저 기록해 검증 대상 줄을 두 번째 이후로 밀어낸다.
|
||||
addLog('warmup');
|
||||
|
||||
$korean = 'Composer 의존성 설치를 시작합니다.';
|
||||
|
||||
addLog($korean);
|
||||
$logs = getInstallationLogs();
|
||||
$message = $logs[count($logs) - 1]['message'] ?? '';
|
||||
|
||||
$this->assertSame($korean, $message, '정상 UTF-8 한글은 scrub 후에도 변형되지 않아야 한다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 이벤트 스트림도 invalid UTF-8 로그로 인해 빈 data 라인을 송출하면 안 된다 (인코딩 계약).
|
||||
*
|
||||
* 폴링 모드로 제보됐으나(#62), 근본 원인(외부 유래 invalid UTF-8 → json_encode false)은
|
||||
* SSE 모드에도 동일하게 존재한다. SseEmitter::emit 의 json_encode 가 false 를 반환하면
|
||||
* "data: \n\n" 이 송출되어 프론트 EventSource 의 JSON.parse(e.data) 가 throw → 로그 이벤트 유실.
|
||||
*
|
||||
* SseEmitter::emit 은 내부에서 즉시 flush() 하므로 ob_get_clean() 으로 출력을 캡처할 수 없다
|
||||
* (SSE 스트리밍 설계상 의도된 동작). 따라서 emit 이 사용하는 인코딩 계약을 직접 검증한다:
|
||||
* JSON_INVALID_UTF8_SUBSTITUTE 를 적용하면 invalid UTF-8 이 섞여도 false 가 아니어야 한다.
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('invalidUtf8Provider')]
|
||||
public function sse_emit_encoding_contract_never_returns_false_for_invalid_utf8(string $rawMessage): void
|
||||
{
|
||||
// emit 이 실제 사용하는 것과 동일한 인코딩 호출.
|
||||
$payload = json_encode(
|
||||
['message' => $rawMessage],
|
||||
JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE
|
||||
);
|
||||
|
||||
$this->assertNotFalse(
|
||||
$payload,
|
||||
'SSE data 인코딩은 invalid UTF-8 로그가 섞여도 false 를 반환하면 안 된다 '.
|
||||
'(false → "data: \n\n" → 프론트 JSON.parse throw → 로그 이벤트 유실).'
|
||||
);
|
||||
$this->assertIsString($payload);
|
||||
|
||||
// 프론트 JSON.parse(e.data) 와 동일하게 파싱 가능해야 한다.
|
||||
$decoded = json_decode($payload, true);
|
||||
$this->assertSame(JSON_ERROR_NONE, json_last_error());
|
||||
$this->assertIsArray($decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* progress-emitter.php 의 SseEmitter::emit 이 JSON_INVALID_UTF8_SUBSTITUTE 를 적용하는지
|
||||
* 소스 레벨로 회귀 검증한다 (PollingResponseFlushTest 정적 검증과 동일 방식).
|
||||
*/
|
||||
#[Test]
|
||||
public function sse_emitter_source_uses_invalid_utf8_substitute_flag(): void
|
||||
{
|
||||
$source = (string) file_get_contents(
|
||||
dirname(__DIR__, 3).'/public/install/includes/progress-emitter.php'
|
||||
);
|
||||
|
||||
// SseEmitter 클래스 본문 추출
|
||||
$classStart = strpos($source, 'class SseEmitter');
|
||||
$this->assertNotFalse($classStart, 'SseEmitter 클래스를 찾을 수 없습니다.');
|
||||
$classEnd = strpos($source, 'class NullEmitter', $classStart);
|
||||
$sseBody = substr($source, $classStart, $classEnd !== false ? $classEnd - $classStart : null);
|
||||
|
||||
$this->assertStringContainsString(
|
||||
'JSON_INVALID_UTF8_SUBSTITUTE',
|
||||
$sseBody,
|
||||
'SseEmitter::emit 의 json_encode 는 JSON_INVALID_UTF8_SUBSTITUTE 를 적용해 '.
|
||||
'invalid UTF-8 로그로 인한 빈 SSE data 라인 송출을 차단해야 한다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -43,12 +43,17 @@ class DeleteDirectoryTest extends TestCase
|
||||
define('INSTALLER_BASE_URL', '/install');
|
||||
}
|
||||
|
||||
// 인스톨러 함수 로드 (pure PHPUnit TestCase 라서 Laravel base_path() 사용 불가 — BASE_PATH 상수 사용)
|
||||
require_once BASE_PATH . '/public/install/includes/functions.php';
|
||||
require_once BASE_PATH . '/public/install/api/rollback-functions.php';
|
||||
// 인스톨러 함수 로드 (pure PHPUnit TestCase 라서 Laravel base_path() 사용 불가).
|
||||
// 프로젝트 루트 절대경로(__DIR__ 기준)로 로드한다 — 다른 인스톨러 테스트
|
||||
// (#[RunClassInSeparateProcess] + temp BASE_PATH)가 메인 프로세스로 BASE_PATH 를
|
||||
// 누수시켜도 그 temp 경로에는 public/install 이 없어 require 가 깨지므로, 누수된
|
||||
// BASE_PATH 값과 무관하게 항상 실제 프로젝트 파일을 참조한다.
|
||||
$installerRoot = dirname(__DIR__, 3);
|
||||
require_once $installerRoot.'/public/install/includes/functions.php';
|
||||
require_once $installerRoot.'/public/install/api/rollback-functions.php';
|
||||
|
||||
// 임시 디렉토리 생성
|
||||
$this->tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'g7_test_delete_' . uniqid();
|
||||
$this->tempDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'g7_test_delete_'.uniqid();
|
||||
mkdir($this->tempDir, 0755, true);
|
||||
}
|
||||
|
||||
@@ -67,10 +72,10 @@ class DeleteDirectoryTest extends TestCase
|
||||
public function test_preserves_gitkeep_file(): void
|
||||
{
|
||||
// Given: 디렉토리에 .gitkeep과 일반 파일이 있음
|
||||
file_put_contents($this->tempDir . '/.gitkeep', '');
|
||||
file_put_contents($this->tempDir . '/some_file.txt', 'test');
|
||||
mkdir($this->tempDir . '/subdir', 0755);
|
||||
file_put_contents($this->tempDir . '/subdir/nested.txt', 'test');
|
||||
file_put_contents($this->tempDir.'/.gitkeep', '');
|
||||
file_put_contents($this->tempDir.'/some_file.txt', 'test');
|
||||
mkdir($this->tempDir.'/subdir', 0755);
|
||||
file_put_contents($this->tempDir.'/subdir/nested.txt', 'test');
|
||||
|
||||
// When: deleteDirectory 호출 (기본: removeDir=false)
|
||||
$result = deleteDirectory($this->tempDir);
|
||||
@@ -78,9 +83,9 @@ class DeleteDirectoryTest extends TestCase
|
||||
// Then: .gitkeep은 보존, 나머지는 삭제, 디렉토리 자체는 유지
|
||||
$this->assertTrue($result);
|
||||
$this->assertDirectoryExists($this->tempDir);
|
||||
$this->assertFileExists($this->tempDir . '/.gitkeep');
|
||||
$this->assertFileDoesNotExist($this->tempDir . '/some_file.txt');
|
||||
$this->assertDirectoryDoesNotExist($this->tempDir . '/subdir');
|
||||
$this->assertFileExists($this->tempDir.'/.gitkeep');
|
||||
$this->assertFileDoesNotExist($this->tempDir.'/some_file.txt');
|
||||
$this->assertDirectoryDoesNotExist($this->tempDir.'/subdir');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,8 +94,8 @@ class DeleteDirectoryTest extends TestCase
|
||||
public function test_preserves_gitignore_file(): void
|
||||
{
|
||||
// Given: 디렉토리에 .gitignore와 일반 파일이 있음
|
||||
file_put_contents($this->tempDir . '/.gitignore', '*');
|
||||
file_put_contents($this->tempDir . '/data.log', 'log');
|
||||
file_put_contents($this->tempDir.'/.gitignore', '*');
|
||||
file_put_contents($this->tempDir.'/data.log', 'log');
|
||||
|
||||
// When
|
||||
$result = deleteDirectory($this->tempDir);
|
||||
@@ -98,8 +103,8 @@ class DeleteDirectoryTest extends TestCase
|
||||
// Then
|
||||
$this->assertTrue($result);
|
||||
$this->assertDirectoryExists($this->tempDir);
|
||||
$this->assertFileExists($this->tempDir . '/.gitignore');
|
||||
$this->assertFileDoesNotExist($this->tempDir . '/data.log');
|
||||
$this->assertFileExists($this->tempDir.'/.gitignore');
|
||||
$this->assertFileDoesNotExist($this->tempDir.'/data.log');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,8 +113,8 @@ class DeleteDirectoryTest extends TestCase
|
||||
public function test_keeps_directory_when_no_preserve_files_and_remove_dir_false(): void
|
||||
{
|
||||
// Given: 보호 파일 없이 일반 파일만 있음
|
||||
file_put_contents($this->tempDir . '/file1.txt', 'test');
|
||||
file_put_contents($this->tempDir . '/file2.txt', 'test');
|
||||
file_put_contents($this->tempDir.'/file1.txt', 'test');
|
||||
file_put_contents($this->tempDir.'/file2.txt', 'test');
|
||||
|
||||
// When: removeDir=false (기본값)
|
||||
$result = deleteDirectory($this->tempDir);
|
||||
@@ -117,8 +122,8 @@ class DeleteDirectoryTest extends TestCase
|
||||
// Then: 디렉토리 자체는 유지, 내용물은 삭제
|
||||
$this->assertTrue($result);
|
||||
$this->assertDirectoryExists($this->tempDir);
|
||||
$this->assertFileDoesNotExist($this->tempDir . '/file1.txt');
|
||||
$this->assertFileDoesNotExist($this->tempDir . '/file2.txt');
|
||||
$this->assertFileDoesNotExist($this->tempDir.'/file1.txt');
|
||||
$this->assertFileDoesNotExist($this->tempDir.'/file2.txt');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,8 +132,8 @@ class DeleteDirectoryTest extends TestCase
|
||||
public function test_keeps_directory_when_preserve_files_exist_even_with_remove_dir(): void
|
||||
{
|
||||
// Given: .gitkeep이 있음
|
||||
file_put_contents($this->tempDir . '/.gitkeep', '');
|
||||
file_put_contents($this->tempDir . '/other.txt', 'test');
|
||||
file_put_contents($this->tempDir.'/.gitkeep', '');
|
||||
file_put_contents($this->tempDir.'/other.txt', 'test');
|
||||
|
||||
// When: removeDir=true여도 보호 파일이 있으면 디렉토리 유지
|
||||
$result = deleteDirectory($this->tempDir, true);
|
||||
@@ -136,8 +141,8 @@ class DeleteDirectoryTest extends TestCase
|
||||
// Then
|
||||
$this->assertTrue($result);
|
||||
$this->assertDirectoryExists($this->tempDir);
|
||||
$this->assertFileExists($this->tempDir . '/.gitkeep');
|
||||
$this->assertFileDoesNotExist($this->tempDir . '/other.txt');
|
||||
$this->assertFileExists($this->tempDir.'/.gitkeep');
|
||||
$this->assertFileDoesNotExist($this->tempDir.'/other.txt');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +151,7 @@ class DeleteDirectoryTest extends TestCase
|
||||
public function test_removes_directory_when_no_preserve_files_and_remove_dir_true(): void
|
||||
{
|
||||
// Given: 보호 파일 없음
|
||||
file_put_contents($this->tempDir . '/file.txt', 'test');
|
||||
file_put_contents($this->tempDir.'/file.txt', 'test');
|
||||
|
||||
// When
|
||||
$result = deleteDirectory($this->tempDir, true);
|
||||
@@ -161,10 +166,10 @@ class DeleteDirectoryTest extends TestCase
|
||||
public function test_handles_nested_directories(): void
|
||||
{
|
||||
// Given: vendor 디렉토리 구조 시뮬레이션
|
||||
file_put_contents($this->tempDir . '/.gitkeep', '');
|
||||
mkdir($this->tempDir . '/laravel/framework/src', 0755, true);
|
||||
file_put_contents($this->tempDir . '/laravel/framework/src/Application.php', '<?php');
|
||||
file_put_contents($this->tempDir . '/autoload.php', '<?php');
|
||||
file_put_contents($this->tempDir.'/.gitkeep', '');
|
||||
mkdir($this->tempDir.'/laravel/framework/src', 0755, true);
|
||||
file_put_contents($this->tempDir.'/laravel/framework/src/Application.php', '<?php');
|
||||
file_put_contents($this->tempDir.'/autoload.php', '<?php');
|
||||
|
||||
// When
|
||||
$result = deleteDirectory($this->tempDir);
|
||||
@@ -172,9 +177,9 @@ class DeleteDirectoryTest extends TestCase
|
||||
// Then: .gitkeep만 보존, 나머지 전부 삭제
|
||||
$this->assertTrue($result);
|
||||
$this->assertDirectoryExists($this->tempDir);
|
||||
$this->assertFileExists($this->tempDir . '/.gitkeep');
|
||||
$this->assertFileDoesNotExist($this->tempDir . '/autoload.php');
|
||||
$this->assertDirectoryDoesNotExist($this->tempDir . '/laravel');
|
||||
$this->assertFileExists($this->tempDir.'/.gitkeep');
|
||||
$this->assertFileDoesNotExist($this->tempDir.'/autoload.php');
|
||||
$this->assertDirectoryDoesNotExist($this->tempDir.'/laravel');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,24 +200,24 @@ class DeleteDirectoryTest extends TestCase
|
||||
*/
|
||||
public function test_returns_false_for_nonexistent_directory(): void
|
||||
{
|
||||
$result = deleteDirectory($this->tempDir . '/nonexistent');
|
||||
$result = deleteDirectory($this->tempDir.'/nonexistent');
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 디렉토리를 완전히 정리합니다 (테스트용).
|
||||
*
|
||||
* @param string $dir 정리할 디렉토리 경로
|
||||
* @param string $dir 정리할 디렉토리 경로
|
||||
*/
|
||||
private function cleanupTempDir(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
if (! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = array_diff(scandir($dir), ['.', '..']);
|
||||
foreach ($items as $item) {
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
||||
$path = $dir.DIRECTORY_SEPARATOR.$item;
|
||||
if (is_dir($path)) {
|
||||
$this->cleanupTempDir($path);
|
||||
} else {
|
||||
|
||||
@@ -11,6 +11,19 @@ use PHPUnit\Framework\TestCase;
|
||||
*/
|
||||
class InstallerWindowsCommandsTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 프로젝트 루트 절대경로 (BASE_PATH 상수와 독립).
|
||||
*
|
||||
* 다른 인스톨러 테스트(#[RunClassInSeparateProcess] + temp BASE_PATH)가 메인
|
||||
* 프로세스로 BASE_PATH 를 누수시키면, 그 temp 경로 하위에는 public/install 이 없어
|
||||
* require 가 깨진다. 본 클래스는 __DIR__ 기준 절대경로로 인스톨러 파일을 로드해
|
||||
* 누수된 BASE_PATH 값과 무관하게 항상 실제 프로젝트 파일을 참조한다.
|
||||
*/
|
||||
private function installerRoot(): string
|
||||
{
|
||||
return dirname(__DIR__, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 전 인스톨러 설정 및 함수 파일을 로드합니다.
|
||||
*/
|
||||
@@ -40,8 +53,8 @@ class InstallerWindowsCommandsTest extends TestCase
|
||||
define('INSTALLER_BASE_URL', '/install');
|
||||
}
|
||||
|
||||
// functions.php 로드 (한 번만) — base_path() 대신 BASE_PATH 상수 직접 사용
|
||||
require_once BASE_PATH . '/public/install/includes/functions.php';
|
||||
// functions.php 로드 (한 번만) — 누수된 BASE_PATH 와 무관하게 프로젝트 루트 절대경로 사용
|
||||
require_once $this->installerRoot().'/public/install/includes/functions.php';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,8 +178,8 @@ class InstallerWindowsCommandsTest extends TestCase
|
||||
*/
|
||||
public function test_translation_keys_exist(): void
|
||||
{
|
||||
$koTranslations = require BASE_PATH . '/public/install/lang/ko.php';
|
||||
$enTranslations = require BASE_PATH . '/public/install/lang/en.php';
|
||||
$koTranslations = require $this->installerRoot().'/public/install/lang/ko.php';
|
||||
$enTranslations = require $this->installerRoot().'/public/install/lang/en.php';
|
||||
|
||||
$this->assertArrayHasKey('permission_windows_hint', $koTranslations, 'ko.php에 permission_windows_hint 키가 없습니다');
|
||||
$this->assertArrayHasKey('permission_windows_hint', $enTranslations, 'en.php에 permission_windows_hint 키가 없습니다');
|
||||
@@ -179,8 +192,8 @@ class InstallerWindowsCommandsTest extends TestCase
|
||||
*/
|
||||
public function test_directory_create_guide_translation_keys_exist(): void
|
||||
{
|
||||
$koTranslations = require BASE_PATH . '/public/install/lang/ko.php';
|
||||
$enTranslations = require BASE_PATH . '/public/install/lang/en.php';
|
||||
$koTranslations = require $this->installerRoot().'/public/install/lang/ko.php';
|
||||
$enTranslations = require $this->installerRoot().'/public/install/lang/en.php';
|
||||
|
||||
$this->assertArrayHasKey('directory_create_guide', $koTranslations, 'ko.php에 directory_create_guide 키가 없습니다');
|
||||
$this->assertArrayHasKey('directory_create_guide', $enTranslations, 'en.php에 directory_create_guide 키가 없습니다');
|
||||
|
||||
Reference in New Issue
Block a user