fix(vendor-bundle): 코어 빌드 무한 행 해결 + 이커머스 알림 라벨 raw 노출 수정
이커머스 알림 정의 화면에서 order_pending_deposit·order_delivered 두 알림이 라벨 대신 raw 다국어 키로 표시되던 문제를 수정. settings.json 3로케일에 라벨 추가 + 렌더 회귀 테스트 + 브라우저 실측. vendor-bundle 시스템 결함 5종 해결: - (A/B) manifest 해시 기준을 활성이 아닌 출력(_bundled) 디렉토리로 통일해 확장 업데이트가 composer_json_sha_mismatch 순환에 빠지던 문제 해소 - (C) 코어 빌드 시 composer autoload dump 의 async 손자 프로세스가 부모 파이프 핸들을 상속·점유해 무한 대기하던 교착을, stdout/stderr 를 파이프 아닌 파일 descriptor 로 받아 원천 차단 (파이프=행/파일=완주 A/B 실측으로 확정) - (D) 빌드 실패 시 원본 번들 유실 방지 — 임시파일 성공 후 원자적 교체 - (E) Windows .bat 실행 시 bypass_shell 통일 코어 7.0.4 / 이커머스·언어팩 1.0.3 bump.
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@ APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.3
|
||||
APP_VERSION=7.0.4
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -3,7 +3,7 @@ APP_ENV=testing
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.3
|
||||
APP_VERSION=7.0.4
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.4] - 2026-07-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- 외부 라이브러리를 쓰는 확장의 버전을 올린 뒤 그 확장을 업데이트하면 "번들 파일의 무결성 검증에 실패했습니다"라는 오류로 업데이트가 막히던 문제를 수정했습니다. 오류를 풀려면 업데이트가 필요한데 그 업데이트 자체가 막혀 빠져나올 수 없는 상태였습니다. 아울러 확장 버전만 올린 경우에도 번들 재빌드가 필요함을 `--check` 점검이 올바르게 알려주도록 바로잡았습니다. (이미 배포된 확장을 내려받아 쓰는 환경에는 영향이 없으며, 확장을 직접 개발·수정하는 경우에 해당합니다.)
|
||||
- Windows 개발 환경에서 코어 의존성 번들을 다시 만들 때, 마지막 정리 단계에서 진행이 멈춘 채 끝나지 않던 문제를 수정했습니다. 이제 번들 빌드가 정상적으로 완료됩니다. 아울러 빌드가 중간에 실패하더라도 기존 번들 파일이 손상되지 않도록 새 결과가 완성된 뒤에만 교체하도록 개선했습니다. (확장을 직접 개발·수정하는 환경에 해당하며, 배포본을 내려받아 쓰는 환경에는 영향이 없습니다.)
|
||||
|
||||
## [7.0.3] - 2026-07-10
|
||||
|
||||
### Fixed
|
||||
|
||||
+1
-1
@@ -246,7 +246,7 @@ unzip g7-release.zip
|
||||
|
||||
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
|
||||
ls -la
|
||||
# (필요 시) mv g7-7.0.3 g7
|
||||
# (필요 시) mv g7-7.0.4 g7
|
||||
|
||||
# ZIP 파일 정리 (선택)
|
||||
rm g7-release.zip
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.3-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.4-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
|
||||
+11
-5
@@ -71,7 +71,7 @@ class EnvironmentDetector
|
||||
* → PATH 검색 → composer.phar
|
||||
*
|
||||
* @param string|null $hint 바이너리 경로 힌트 (null 이면 config/ENV/PATH 순으로 자동 탐색)
|
||||
* @return string|null 발견된 composer 바이너리 경로. 없으면 null
|
||||
* @return string|null 발견된 composer 바이너리 경로. 없으면 null
|
||||
*/
|
||||
public function findComposerBinary(?string $hint = null): ?string
|
||||
{
|
||||
@@ -120,7 +120,7 @@ class EnvironmentDetector
|
||||
* proc_open 사용 가능 + composer 바이너리 발견 + `composer --version` 종료 코드 0.
|
||||
*
|
||||
* @param string|null $hint composer 바이너리 경로 힌트 (캐시 우회용)
|
||||
* @return bool composer 가 현재 환경에서 실행 가능한지 여부
|
||||
* @return bool composer 가 현재 환경에서 실행 가능한지 여부
|
||||
*/
|
||||
public function canExecuteComposer(?string $hint = null): bool
|
||||
{
|
||||
@@ -178,7 +178,7 @@ class EnvironmentDetector
|
||||
* 지정 경로에 쓰기 가능한지 확인.
|
||||
*
|
||||
* @param string $targetPath vendor 디렉토리 경로 (존재하지 않아도 부모 디렉토리 쓰기 권한 검사)
|
||||
* @return bool 부모 디렉토리가 존재하고 쓰기 가능한지 여부
|
||||
* @return bool 부모 디렉토리가 존재하고 쓰기 가능한지 여부
|
||||
*/
|
||||
public function canWriteVendor(string $targetPath): bool
|
||||
{
|
||||
@@ -254,9 +254,15 @@ class EnvironmentDetector
|
||||
/**
|
||||
* composer 실행 명령 문자열 구성.
|
||||
*
|
||||
* @param array<int, string> $args
|
||||
* 반환된 문자열은 `proc_open(..., ['bypass_shell' => true])` 와 함께 사용해야 한다.
|
||||
* Windows 에서 `bypass_shell` 없이 실행하면 `cmd.exe` 가 따옴표로 시작하는 `.bat`
|
||||
* 경로의 앞뒤 따옴표를 벗겨 첫 인자(`install` 등)를 실행 파일로 오인한다.
|
||||
*
|
||||
* @param string $binary composer 바이너리 경로 (findComposerBinary 반환값)
|
||||
* @param array<int, string> $args composer 하위 명령과 옵션 (예: ['install', '--no-dev'])
|
||||
* @return string proc_open 에 넘길 실행 명령 문자열
|
||||
*/
|
||||
private function buildComposerCommand(string $binary, array $args): string
|
||||
public function buildComposerCommand(string $binary, array $args): string
|
||||
{
|
||||
$escaped = array_map('escapeshellarg', $args);
|
||||
|
||||
|
||||
+160
-50
@@ -82,7 +82,7 @@ class VendorBundler
|
||||
* 코어는 소스 = 출력 경로가 base_path() 로 동일합니다.
|
||||
*
|
||||
* @param bool $force manifest 해시 일치 여부와 무관하게 강제 재빌드
|
||||
* @return VendorBundleResult 번들 생성 결과 (zip 경로, manifest 경로, 패키지 수 등)
|
||||
* @return VendorBundleResult 번들 생성 결과 (zip 경로, manifest 경로, 패키지 수 등)
|
||||
*/
|
||||
public function buildForCore(bool $force = false): VendorBundleResult
|
||||
{
|
||||
@@ -106,7 +106,7 @@ class VendorBundler
|
||||
* @param string $type 확장 타입 ('module' 또는 'plugin')
|
||||
* @param string $identifier 확장 식별자 (vendor-name 형식, 예: 'sirsoft-ecommerce')
|
||||
* @param bool $force manifest 해시 일치 여부와 무관하게 강제 재빌드
|
||||
* @return VendorBundleResult 번들 생성 결과 (활성 디렉토리 부재 시 skipped=true)
|
||||
* @return VendorBundleResult 번들 생성 결과 (활성 디렉토리 부재 시 skipped=true)
|
||||
*/
|
||||
public function buildForExtension(string $type, string $identifier, bool $force = false): VendorBundleResult
|
||||
{
|
||||
@@ -164,7 +164,7 @@ class VendorBundler
|
||||
*
|
||||
* @param string $sourcePath composer.json/lock 을 읽을 경로 (활성 디렉토리)
|
||||
* @param string|null $outputPath 번들 출력 경로 (null 이면 sourcePath 와 동일 — 코어 케이스)
|
||||
* @return bool 번들이 stale 한지 여부 (해시 불일치 또는 manifest 부재). 외부 의존성이 없으면 항상 false
|
||||
* @return bool 번들이 stale 한지 여부 (해시 불일치 또는 manifest 부재). 외부 의존성이 없으면 항상 false
|
||||
*/
|
||||
public function isStale(string $sourcePath, ?string $outputPath = null): bool
|
||||
{
|
||||
@@ -187,16 +187,19 @@ class VendorBundler
|
||||
return true;
|
||||
}
|
||||
|
||||
if (file_exists($composerJsonPath)) {
|
||||
$currentHash = $this->integrityChecker->computeFileHash($composerJsonPath);
|
||||
// 해시 비교 대상은 manifest 가 놓인 출력 디렉토리 기준 ({@see build()} 참조).
|
||||
// 소스만 검사하면 _bundled 의 composer.json 변경을 up-to-date 로 오보한다.
|
||||
$hashJsonPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.json');
|
||||
if ($hashJsonPath !== null) {
|
||||
$currentHash = $this->integrityChecker->computeFileHash($hashJsonPath);
|
||||
if (($manifest['composer_json_sha256'] ?? null) !== $currentHash) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$composerLockPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.lock';
|
||||
if (file_exists($composerLockPath)) {
|
||||
$currentHash = $this->integrityChecker->computeFileHash($composerLockPath);
|
||||
$hashLockPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.lock');
|
||||
if ($hashLockPath !== null) {
|
||||
$currentHash = $this->integrityChecker->computeFileHash($hashLockPath);
|
||||
if (($manifest['composer_lock_sha256'] ?? null) !== $currentHash) {
|
||||
return true;
|
||||
}
|
||||
@@ -205,6 +208,35 @@ class VendorBundler
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest 해시 비교 기준이 될 파일 경로를 결정합니다.
|
||||
*
|
||||
* manifest 와 zip 은 출력 디렉토리(_bundled)에 함께 저장되어 그대로 배포되며,
|
||||
* 런타임 검증({@see VendorIntegrityChecker::verify()})은 zip 이 놓인 디렉토리
|
||||
* (_bundled 또는 설치/업데이트 시 그 복사본인 _pending/staging)의 composer.json 을
|
||||
* 대조합니다. 따라서 해시도 그 디렉토리를 기준으로 계산해야 생성자와 검증자의
|
||||
* 기준이 일치합니다.
|
||||
*
|
||||
* 출력 디렉토리에 파일이 없으면 소스(활성 디렉토리)로 폴백합니다.
|
||||
* 코어는 소스와 출력이 동일하므로 어느 쪽을 골라도 같은 경로가 됩니다.
|
||||
*
|
||||
* @param string $sourcePath composer.json/lock 과 vendor/ 를 읽는 경로 (활성 디렉토리)
|
||||
* @param string $outputPath manifest 와 zip 을 쓰는 경로 (_bundled, 코어는 소스와 동일)
|
||||
* @param string $filename 'composer.json' 또는 'composer.lock'
|
||||
* @return string|null 해시 대상 파일의 절대 경로. 양쪽 모두 없으면 null
|
||||
*/
|
||||
private function resolveHashTarget(string $sourcePath, string $outputPath, string $filename): ?string
|
||||
{
|
||||
$outputFile = $outputPath.DIRECTORY_SEPARATOR.$filename;
|
||||
if (file_exists($outputFile)) {
|
||||
return $outputFile;
|
||||
}
|
||||
|
||||
$sourceFile = $sourcePath.DIRECTORY_SEPARATOR.$filename;
|
||||
|
||||
return file_exists($sourceFile) ? $sourceFile : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 실행.
|
||||
*
|
||||
@@ -258,20 +290,25 @@ class VendorBundler
|
||||
);
|
||||
}
|
||||
|
||||
// 기존 파일 제거
|
||||
if (file_exists($zipPath)) {
|
||||
@unlink($zipPath);
|
||||
}
|
||||
if (file_exists($manifestPath)) {
|
||||
@unlink($manifestPath);
|
||||
}
|
||||
|
||||
// === 스테이징 빌드 ===
|
||||
// 개발 머신의 vendor/ 를 직접 사용하지 않고, composer install --no-dev 를
|
||||
// 스테이징 디렉토리에서 새로 실행하여 dev 의존성과 완전히 분리된 번들을 생성한다.
|
||||
//
|
||||
// 새 zip/manifest 는 임시 경로(.tmp)에 먼저 쓰고, 빌드가 성공한 뒤에만 원본을
|
||||
// 교체한다. 기존 파일을 빌드 전에 삭제하면 composer install 실패 시 새 파일이
|
||||
// 생성되지 못한 채 원본만 유실되어 이후 module:update 가 번들 부재로 실패한다.
|
||||
$stagingDir = storage_path('app/vendor-bundle-staging/'.uniqid('build-', true));
|
||||
File::ensureDirectoryExists($stagingDir, 0755);
|
||||
|
||||
$tmpZipPath = $zipPath.'.tmp';
|
||||
$tmpManifestPath = $manifestPath.'.tmp';
|
||||
if (file_exists($tmpZipPath)) {
|
||||
@unlink($tmpZipPath);
|
||||
}
|
||||
if (file_exists($tmpManifestPath)) {
|
||||
@unlink($tmpManifestPath);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. composer.json + composer.lock 을 스테이징으로 복사
|
||||
File::copy($composerJsonPath, $stagingDir.DIRECTORY_SEPARATOR.'composer.json');
|
||||
@@ -292,8 +329,8 @@ class VendorBundler
|
||||
);
|
||||
}
|
||||
|
||||
// 4. zip 생성 (EXCLUDE_PATTERNS 만 적용 — 화이트리스트 불필요)
|
||||
[$zipSize, $fileCount] = $this->writeZip($stagedVendor, $zipPath);
|
||||
// 4. zip 생성 — 임시 경로에 먼저 쓴다 (EXCLUDE_PATTERNS 만 적용)
|
||||
[$zipSize, $fileCount] = $this->writeZip($stagedVendor, $tmpZipPath);
|
||||
|
||||
// 5. manifest 작성 — 스테이징의 composer.lock 을 기준으로 패키지 목록 추출
|
||||
$stagedLockPath = $stagingDir.DIRECTORY_SEPARATOR.'composer.lock';
|
||||
@@ -301,27 +338,35 @@ class VendorBundler
|
||||
? $this->collectPackages($stagedLockPath)
|
||||
: [];
|
||||
|
||||
// manifest 의 해시는 **출력 디렉토리** 의 composer.json/lock 기준.
|
||||
// manifest·zip·composer.json 이 함께 배포되는 곳이 출력 디렉토리이며,
|
||||
// 런타임 검증도 그 디렉토리를 대조한다 ({@see resolveHashTarget()}).
|
||||
$hashJsonPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.json');
|
||||
$hashLockPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.lock');
|
||||
|
||||
$manifest = [
|
||||
'schema_version' => self::SCHEMA_VERSION,
|
||||
'generated_at' => date('c'),
|
||||
'generator' => 'g7 vendor-bundle:build',
|
||||
'target' => $target,
|
||||
// manifest 의 해시는 **소스** 의 composer.json/lock 기준 — 런타임에서
|
||||
// 소스 디렉토리(또는 번들과 함께 배포된) composer.json 과 비교하여
|
||||
// stale 여부를 감지한다.
|
||||
'composer_json_sha256' => $this->integrityChecker->computeFileHash($composerJsonPath),
|
||||
'composer_lock_sha256' => file_exists($sourceLockPath)
|
||||
? $this->integrityChecker->computeFileHash($sourceLockPath)
|
||||
'composer_json_sha256' => $this->integrityChecker->computeFileHash($hashJsonPath ?? $composerJsonPath),
|
||||
'composer_lock_sha256' => $hashLockPath !== null
|
||||
? $this->integrityChecker->computeFileHash($hashLockPath)
|
||||
: null,
|
||||
'zip_sha256' => $this->integrityChecker->computeFileHash($zipPath),
|
||||
'zip_sha256' => $this->integrityChecker->computeFileHash($tmpZipPath),
|
||||
'zip_size' => $zipSize,
|
||||
'package_count' => count($packages),
|
||||
'php_requirement' => $this->extractPhpRequirement($composerJsonPath),
|
||||
'php_requirement' => $this->extractPhpRequirement($hashJsonPath ?? $composerJsonPath),
|
||||
'g7_version' => config('app.version'),
|
||||
'packages' => $packages,
|
||||
];
|
||||
|
||||
File::put($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
File::put($tmpManifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
// 6. 원자적 교체 — 여기까지 성공했으므로 임시 파일을 최종 경로로 옮긴다.
|
||||
// 이 지점 이전에 실패하면 기존 번들이 그대로 보존된다.
|
||||
$this->promoteAtomic($tmpZipPath, $zipPath);
|
||||
$this->promoteAtomic($tmpManifestPath, $manifestPath);
|
||||
|
||||
Log::info('Vendor 번들 빌드 완료', [
|
||||
'target' => $target,
|
||||
@@ -340,10 +385,42 @@ class VendorBundler
|
||||
reason: 'built',
|
||||
);
|
||||
} finally {
|
||||
// 스테이징 정리 (실패/성공 무관)
|
||||
// 스테이징 및 미승격 임시 파일 정리 (실패/성공 무관)
|
||||
if (File::isDirectory($stagingDir)) {
|
||||
File::deleteDirectory($stagingDir);
|
||||
}
|
||||
if (file_exists($tmpZipPath)) {
|
||||
@unlink($tmpZipPath);
|
||||
}
|
||||
if (file_exists($tmpManifestPath)) {
|
||||
@unlink($tmpManifestPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 파일을 최종 경로로 원자적으로 교체합니다.
|
||||
*
|
||||
* Windows 에서 `rename()` 은 대상이 이미 존재하면 실패하므로, 기존 파일을 먼저
|
||||
* 제거한 뒤 이동한다. 이 메서드는 빌드가 성공적으로 완료된 뒤에만 호출되므로,
|
||||
* 여기서 기존 파일을 제거해도 (제거 후 이동 실패 확률이 극히 낮아) 안전하다.
|
||||
*
|
||||
* @param string $tmpPath 임시 파일 경로 (성공적으로 생성 완료된 상태)
|
||||
* @param string $finalPath 최종 경로
|
||||
*
|
||||
* @throws VendorInstallException 이동 실패 시
|
||||
*/
|
||||
private function promoteAtomic(string $tmpPath, string $finalPath): void
|
||||
{
|
||||
if (file_exists($finalPath)) {
|
||||
@unlink($finalPath);
|
||||
}
|
||||
|
||||
if (! @rename($tmpPath, $finalPath)) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'bundle_build_promote_failed',
|
||||
context: ['from' => $tmpPath, 'to' => $finalPath],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,30 +454,37 @@ class VendorBundler
|
||||
);
|
||||
}
|
||||
|
||||
$phpBinary = config('process.php_binary', 'php');
|
||||
|
||||
if (str_contains($binary, ' ')) {
|
||||
$composerCmd = $binary;
|
||||
} elseif (str_ends_with(strtolower($binary), '.phar')) {
|
||||
$composerCmd = escapeshellarg($phpBinary).' '.escapeshellarg($binary);
|
||||
} else {
|
||||
$composerCmd = escapeshellarg($binary);
|
||||
}
|
||||
|
||||
$command = $composerCmd.' install --no-dev --no-scripts --prefer-dist --no-interaction --no-progress 2>&1';
|
||||
// 명령 조립과 실행 옵션은 canExecuteComposer 와 동일한 방식으로 통일한다.
|
||||
// buildComposerCommand + bypass_shell:true 를 함께 써야 Windows 에서 .bat
|
||||
// 경로가 cmd.exe 의 따옴표 처리로 깨지지 않는다.
|
||||
$command = $detector->buildComposerCommand($binary, [
|
||||
'install', '--no-dev', '--no-scripts', '--prefer-dist', '--no-interaction', '--no-progress',
|
||||
]);
|
||||
|
||||
Log::info('vendor-bundle 빌드: composer install 시작', [
|
||||
'target' => $target,
|
||||
'staging' => $stagingDir,
|
||||
]);
|
||||
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
// stdout/stderr 는 파이프가 아닌 파일 descriptor 로 받는다.
|
||||
// (Windows 회귀) composer 는 autoload dump 단계에서 임시 추출물을
|
||||
// `rmdir /S /Q` async `cmd.exe` 손자 프로세스로 정리하는데, stdout/stderr 를
|
||||
// 파이프로 열면 이 손자들이 부모 파이프 핸들을 상속받아 붙잡은 채 종료하지 않아
|
||||
// 부모(php)의 read/proc_close 가 자식 완료 신호를 영영 받지 못하고 무한 대기한다.
|
||||
// 파일 descriptor 는 손자에게 상속될 열린 파이프 핸들이 없어 이 교착을 원천 차단한다
|
||||
// (실측: 파이프=행, 파일=140s 완주 EXIT 0 + autoload 정상 생성).
|
||||
$outPath = $stagingDir.DIRECTORY_SEPARATOR.'_composer_stdout.log';
|
||||
$errPath = $stagingDir.DIRECTORY_SEPARATOR.'_composer_stderr.log';
|
||||
$descriptors = self::composerOutputDescriptors($outPath, $errPath);
|
||||
|
||||
$process = @proc_open($command, $descriptors, $pipes, $stagingDir, EnvironmentDetector::buildComposerEnv());
|
||||
$process = @proc_open(
|
||||
$command,
|
||||
$descriptors,
|
||||
$pipes,
|
||||
$stagingDir,
|
||||
EnvironmentDetector::buildComposerEnv(),
|
||||
['bypass_shell' => true],
|
||||
);
|
||||
|
||||
if (! is_resource($process)) {
|
||||
throw new VendorInstallException(
|
||||
@@ -409,12 +493,17 @@ class VendorBundler
|
||||
);
|
||||
}
|
||||
|
||||
fclose($pipes[0]);
|
||||
$output = stream_get_contents($pipes[1]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
// stdin(파이프)만 즉시 닫고, stdout/stderr 는 파일이므로 배수 불필요 —
|
||||
// proc_close 가 자식 종료까지 블록 대기한다.
|
||||
if (isset($pipes[0]) && is_resource($pipes[0])) {
|
||||
fclose($pipes[0]);
|
||||
}
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
$stdout = is_file($outPath) ? (string) @file_get_contents($outPath) : '';
|
||||
$stderr = is_file($errPath) ? (string) @file_get_contents($errPath) : '';
|
||||
$output = trim($stdout.\PHP_EOL.$stderr);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'bundle_build_composer_failed',
|
||||
@@ -428,13 +517,34 @@ class VendorBundler
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* composer install 의 stdout/stderr 를 받을 proc_open descriptor 배열을 구성합니다.
|
||||
*
|
||||
* stdout/stderr 를 파이프가 아닌 파일로 여는 이유는 runComposerInstall 주석 참조 —
|
||||
* Windows 에서 composer autoload dump 의 async 손자 프로세스가 상속받는 파이프 핸들로
|
||||
* 인한 무한 대기(행)를 원천 차단한다. 이 메서드는 순수 함수로, descriptor 구조를
|
||||
* 회귀 테스트로 고정하기 위해 분리했다.
|
||||
*
|
||||
* @param string $outPath stdout 로그 파일 경로
|
||||
* @param string $errPath stderr 로그 파일 경로
|
||||
* @return array<int, array<int, string>> proc_open 2번째 인자용 descriptor 배열
|
||||
*/
|
||||
public static function composerOutputDescriptors(string $outPath, string $errPath): array
|
||||
{
|
||||
return [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['file', $outPath, 'w'],
|
||||
2 => ['file', $errPath, 'w'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* vendor/ 디렉토리를 재귀적으로 zip에 쓰기.
|
||||
*
|
||||
* 스테이징에서 composer install --no-dev 로 새로 생성된 vendor/ 를 대상으로 하므로
|
||||
* 화이트리스트 필터링은 불필요하며, EXCLUDE_PATTERNS (tests/, docs/ 등) 만 적용한다.
|
||||
*
|
||||
* @return array{0: int, 1: int} [zipSize, fileCount]
|
||||
* @return array{0: int, 1: int} [zipSize, fileCount]
|
||||
*/
|
||||
private function writeZip(string $vendorPath, string $zipPath): array
|
||||
{
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => env('APP_VERSION', '7.0.3'),
|
||||
'version' => env('APP_VERSION', '7.0.4'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -30,6 +30,28 @@ G7 코어와 모듈/플러그인은 PHP 의존성 관리에 Composer를 사용
|
||||
|
||||
이 네 파일(`composer.json` / `composer.lock` / `vendor-bundle.json` / `vendor-bundle.zip`)은 코어 루트 및 각 `_bundled` 확장 루트에 함께 존재하며 모두 Git 으로 추적한다. 추출 결과인 `vendor/` 디렉토리만 `.gitignore` 대상이다. `composer.lock` 이 누락되면 `VendorIntegrityChecker` 가 `composer_lock_sha256` 을 검증할 수 없으므로, `composer update` 직후 생성된 `composer.lock` 과 재빌드된 번들 두 파일을 같은 커밋에 함께 포함한다.
|
||||
|
||||
### 1.1 소스와 출력의 분리
|
||||
|
||||
확장의 번들 빌드는 읽는 곳과 쓰는 곳이 다르다.
|
||||
|
||||
| 구분 | 경로 | 근거 |
|
||||
|------|------|------|
|
||||
| 소스 (`vendor/` 를 읽는 곳) | 활성 디렉토리 (`modules/{id}`) | `composer install` 이 실행되어 실제 패키지가 설치된 곳 |
|
||||
| 출력 (`vendor-bundle.{json,zip}` 을 쓰는 곳) | `_bundled` 디렉토리 | Git 추적 배포 아티팩트 저장 위치 (`_bundled` 오염 방지) |
|
||||
|
||||
반면 manifest 에 기록하는 `composer_json_sha256` / `composer_lock_sha256` 은 **출력 디렉토리**의 `composer.json` / `composer.lock` 을 기준으로 계산한다 (`VendorBundler::resolveHashTarget()`). 출력 디렉토리에 해당 파일이 없으면 소스로 폴백하며, 코어는 소스와 출력이 동일하므로 결과가 같다.
|
||||
|
||||
이유는 검증자의 대조 위치에 있다. `VendorIntegrityChecker::verify($sourceDir)` 의 `$sourceDir` 은 zip 과 manifest 가 놓인 디렉토리를 뜻하고, 실제 호출 경로는 모두 `_bundled` 계열을 넘긴다.
|
||||
|
||||
| 경로 | verify 에 넘기는 디렉토리 |
|
||||
|------|--------------------------|
|
||||
| 신규 설치 | `_pending/{id}` (`_bundled` 복사본) |
|
||||
| 업데이트 | `_pending/{id}_updating_*` (`_bundled` 복사본) |
|
||||
| 코어 업데이트 | pending 경로 (소스 = 출력) |
|
||||
| `vendor-bundle:verify-all` | `_bundled/{id}` |
|
||||
|
||||
해시를 소스(활성)에서 계산하면 생성 기준과 검증 기준이 어긋난다. 두 `composer.json` 의 내용이 같을 때만 우연히 통과하며, `_bundled` 의 `composer.json` 만 변경된 상태(버전 bump 직후 등)에서는 manifest 가 영구히 불일치하여 `module:update` 가 `composer_json_sha_mismatch` 로 차단된다. 그 오류를 해소할 유일한 명령이 `module:update` 이므로 순환이 발생한다. 같은 이유로 `isStale()` 도 출력 디렉토리를 검사해야 하며, 소스만 보면 `--check` 가 stale 을 `up-to-date` 로 오보한다.
|
||||
|
||||
`vendor-bundle.json` 스키마 (v1.0):
|
||||
|
||||
```json
|
||||
@@ -212,11 +234,11 @@ Content-Type: application/json
|
||||
|
||||
### 7.1 SHA256 검증
|
||||
|
||||
`VendorIntegrityChecker::verify()` 가 다음을 검증합니다:
|
||||
`VendorIntegrityChecker::verify($sourceDir)` 가 다음을 검증합니다. `$sourceDir` 은 zip 과 manifest 가 놓인 디렉토리이며, 확장의 경우 `_bundled` 또는 그 복사본(`_pending`)입니다 (§1.1).
|
||||
|
||||
- vendor-bundle.zip 파일의 실제 SHA256 vs manifest 기록 값
|
||||
- composer.json SHA256 (소스에 존재 시)
|
||||
- composer.lock SHA256 (소스에 존재 시)
|
||||
- composer.json SHA256 (해당 디렉토리에 존재 시)
|
||||
- composer.lock SHA256 (해당 디렉토리에 존재 시)
|
||||
|
||||
검증 실패 시 `VendorInstallException` 이 발생하며 설치가 중단됩니다.
|
||||
|
||||
@@ -230,6 +252,9 @@ Content-Type: application/json
|
||||
## 8. 자동 트리거
|
||||
|
||||
`composer.json` 또는 `composer.lock` 파일이 변경되면 번들이 stale 상태가 됩니다.
|
||||
확장의 경우 판정 기준은 `_bundled` 디렉토리의 두 파일이다 (§1.1). 확장 버전을 올려
|
||||
`_bundled/composer.json` 의 `version` 만 바뀐 경우에도 stale 이 되므로 재빌드가 필요하다.
|
||||
|
||||
다음 명령으로 재빌드해야 합니다:
|
||||
|
||||
```bash
|
||||
@@ -269,6 +294,8 @@ CI 통합 예시:
|
||||
| `composer_not_available` | composer 모드 강제했으나 실행 불가 | --vendor-mode=auto 또는 bundled로 변경 |
|
||||
| `no_vendor_strategy_available` | composer 불가 + 번들 없음 | vendor-bundle.zip 업로드 또는 호스팅 변경 |
|
||||
| `bundle_contains_unsafe_path` | 외부 zip 파일이 zip slip 시도 | 신뢰할 수 있는 소스에서만 다운로드 |
|
||||
| `bundle_build_promote_failed` | 빌드 산출물을 최종 경로로 원자적 교체 실패 | 디스크 공간/권한 확인 후 재빌드 (원본 번들은 promoteAtomic 으로 보존됨) |
|
||||
| 빌드가 `Generating optimized autoload files` 직후 무한 대기 (Windows) | composer autoload dump 의 async 손자 프로세스가 부모 파이프 핸들을 상속·점유 | 코어 반영됨 — stdout/stderr 를 파이프가 아닌 파일 descriptor 로 실행 (`VendorBundler::composerOutputDescriptors`). 최신 코어에서는 재발하지 않음 |
|
||||
|
||||
## 11. 참고
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-07-11
|
||||
|
||||
### Added
|
||||
|
||||
- vendor-bundle 빌드 결과 반영 실패 안내 메시지 일본어 번역 추가 (`exceptions.vendor.bundle_build_promote_failed`) — 빌드 산출물을 최종 경로로 원자적 교체하는 데 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
## [1.0.2] - 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -60,6 +60,7 @@ return [
|
||||
'no_composer_lock' => 'composer.lockファイルが必要です。まずcomposer installを実行してください: :path',
|
||||
'vendor_dir_not_found' => 'vendor/ディレクトリが存在しません。まずcomposer installを実行してください: :path',
|
||||
'composer_binary_not_found' => 'Composerバイナリが見つかりません。COMPOSER_BINARY環境変数を設定するか、composerをPATHに追加してください。',
|
||||
'bundle_build_promote_failed' => 'vendor-bundle ビルド結果を最終パスに反映できませんでした (:from → :to)',
|
||||
],
|
||||
'concurrent_modification' => '別のユーザーが先に保存しました。(リソース: :resource、現在のバージョン: :current、送信されたバージョン: :expected)',
|
||||
];
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 core Japanese language pack (bundled)",
|
||||
"ja": "G7 コア 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"scope": "core",
|
||||
"target_identifier": null,
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-07-10
|
||||
|
||||
### Added
|
||||
|
||||
- 알림 설정의 "무통장 입금 안내"·"배송 완료" 알림 이름에 대한 일본어 번역을 추가했습니다.
|
||||
|
||||
## [1.0.2] - 2026-07-10
|
||||
|
||||
### Removed
|
||||
|
||||
+3
-1
@@ -458,7 +458,9 @@
|
||||
"new_order_admin": "新規注文(管理者)",
|
||||
"inquiry_received": "商品問い合わせ受け取り(管理者)",
|
||||
"inquiry_replied": "商品問い合わせ回答",
|
||||
"mileage_expiring_soon": "マイレージ失効予定"
|
||||
"mileage_expiring_soon": "マイレージ失効予定",
|
||||
"order_pending_deposit": "銀行振込のご案内",
|
||||
"order_delivered": "配送完了"
|
||||
},
|
||||
"btn_search": "検索",
|
||||
"btn_reset_filter": "初期化",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
|
||||
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"scope": "module",
|
||||
"target_identifier": "sirsoft-ecommerce",
|
||||
|
||||
@@ -57,6 +57,7 @@ return [
|
||||
'composer_not_available' => 'Composer cannot be executed in this environment. Use bundled mode instead.',
|
||||
'composer_not_available_for_build' => 'Building vendor-bundle requires Composer execution. Install/configure Composer in the development environment and retry.',
|
||||
'bundle_build_composer_failed' => 'composer install failed while building vendor-bundle (exit :exit): :message',
|
||||
'bundle_build_promote_failed' => 'failed to move vendor-bundle build result to its final path (:from → :to)',
|
||||
'composer_execution_failed' => 'Composer execution failed: :message',
|
||||
'bundle_zip_missing' => 'vendor-bundle.zip not found: :path',
|
||||
'bundle_manifest_missing' => 'vendor-bundle.json not found: :path',
|
||||
|
||||
@@ -57,6 +57,7 @@ return [
|
||||
'composer_not_available' => 'Composer를 실행할 수 없는 환경입니다. 번들 모드를 사용하세요.',
|
||||
'composer_not_available_for_build' => 'vendor-bundle 빌드는 Composer 실행이 필요합니다. 개발 환경에서 Composer를 설치/설정한 후 다시 시도하세요.',
|
||||
'bundle_build_composer_failed' => 'vendor-bundle 빌드 중 composer install 실행 실패 (exit :exit): :message',
|
||||
'bundle_build_promote_failed' => 'vendor-bundle 빌드 결과를 최종 경로로 반영하지 못했습니다 (:from → :to)',
|
||||
'composer_execution_failed' => 'Composer 실행에 실패했습니다: :message',
|
||||
'bundle_zip_missing' => 'vendor-bundle.zip 파일을 찾을 수 없습니다: :path',
|
||||
'bundle_manifest_missing' => 'vendor-bundle.json 파일을 찾을 수 없습니다: :path',
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-07-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- 쇼핑몰 환경설정 > 알림 설정에서 "무통장 입금 안내"와 "배송 완료" 두 알림의 이름이 사람이 읽을 수 없는 내부 문자열로 표시되던 문제를 수정했습니다. 이제 다른 알림과 동일하게 이름이 표시되며, 알림을 기본값으로 되돌릴 때 뜨는 확인창과 알림 편집 화면에서도 함께 바로잡혔습니다.
|
||||
|
||||
## [1.0.2] - 2026-07-10
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-ecommerce",
|
||||
"description": "Ecommerce module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "이커머스",
|
||||
"en": "Ecommerce"
|
||||
},
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
// e2e:allow 라벨 데이터(settings.json) + Vitest 렌더 회귀 테스트만 변경 — 레이아웃 동작/구조 무변경. 알림 정의 화면의 실제 렌더 경로($t: 동적 키 해석)는 본 render 테스트가 커버하고, 화면 수준 검증은 별도 브라우저 실측(handoff §10)으로 확인.
|
||||
/**
|
||||
* @file notificationDefinitionTypeLabels.test.tsx
|
||||
* @description 이커머스 알림 정의 화면 — 타입 라벨 다국어 키 raw 노출 회귀 가드.
|
||||
*
|
||||
* 배경: `order_pending_deposit`, `order_delivered` 두 알림 타입이 module.php 에는
|
||||
* 정의됐으나 라벨을 읽는 settings.json 의 `notification_definitions.types` 에 이름이
|
||||
* 등록되지 않아, 알림 정의 화면에서
|
||||
* `sirsoft-ecommerce.admin.settings.notification_definitions.types.order_pending_deposit`
|
||||
* 같은 raw 키가 그대로 노출됐다.
|
||||
*
|
||||
* 본 테스트는 _tab_notification_definitions.json 이 사용하는 실제 바인딩
|
||||
* `$t:sirsoft-ecommerce.admin.settings.notification_definitions.types.{{def.type}}` 을
|
||||
* 재현하고, **실제 settings.json** 라벨을 주입해 두 타입이 raw 키가 아닌 사람이 읽는
|
||||
* 라벨로 렌더되는지 검증한다. settings.json 에서 어느 한 키가 제거되면 이 테스트가
|
||||
* 다시 red 로 떨어져 회귀를 차단한다.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
createLayoutTest,
|
||||
createMockComponentRegistryWithBasics,
|
||||
screen,
|
||||
type MockComponentRegistry,
|
||||
} from '@core/template-engine/__tests__/utils/layoutTestUtils';
|
||||
|
||||
import settingsKo from '../../../lang/partial/ko/admin/settings.json';
|
||||
import settingsEn from '../../../lang/partial/en/admin/settings.json';
|
||||
|
||||
// 실제 화면(_tab_notification_definitions.json line ~371)의 타입 라벨 바인딩을 그대로 축약 재현.
|
||||
// 알림 목록 항목마다 H4 에 `$t:...types.{{def.type}}` 로 이름을 표시한다.
|
||||
const LABEL_BINDING =
|
||||
'$t:sirsoft-ecommerce.admin.settings.notification_definitions.types.{{def.type}}';
|
||||
|
||||
function makeLayout() {
|
||||
return {
|
||||
name: 'notification_definition_type_labels_probe',
|
||||
version: '1.0.0',
|
||||
components: [
|
||||
{
|
||||
type: 'basic',
|
||||
name: 'Div',
|
||||
children: [
|
||||
{
|
||||
type: 'basic',
|
||||
name: 'Div',
|
||||
iteration: {
|
||||
source: '{{ecommerceNotificationDefinitions?.data?.data ?? []}}',
|
||||
item_var: 'def',
|
||||
index_var: 'defIdx',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'basic',
|
||||
name: 'H4',
|
||||
text: LABEL_BINDING,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// 실제 서빙 데이터를 모사한 알림 정의 목록 — 문제의 두 타입 포함.
|
||||
const DEFINITIONS = [
|
||||
{ id: 1, type: 'order_pending_deposit', templates: [], variables: [] },
|
||||
{ id: 2, type: 'order_delivered', templates: [], variables: [] },
|
||||
];
|
||||
|
||||
// settings.json 은 `notification_definitions` 를 admin.settings 하위가 아닌 파일 루트에 둔다.
|
||||
// 화면 바인딩은 `sirsoft-ecommerce.admin.settings.notification_definitions...` 네임스페이스를
|
||||
// 쓰므로, 주입 시 admin.settings 아래로 매핑한다(활성 언어팩 로더의 병합 결과와 동일 구조).
|
||||
function buildTranslations(settings: Record<string, any>) {
|
||||
return {
|
||||
'sirsoft-ecommerce': {
|
||||
admin: {
|
||||
settings: settings,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let registry: MockComponentRegistry;
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('알림 정의 타입 라벨 — 다국어 키 raw 노출 회귀 가드', () => {
|
||||
it('ko: order_pending_deposit / order_delivered 가 라벨로 렌더된다 (raw 키 미노출)', async () => {
|
||||
registry = createMockComponentRegistryWithBasics();
|
||||
const { render, cleanup } = createLayoutTest(makeLayout(), {
|
||||
componentRegistry: registry as any,
|
||||
locale: 'ko',
|
||||
translations: buildTranslations(settingsKo),
|
||||
initialData: {
|
||||
ecommerceNotificationDefinitions: { data: { data: DEFINITIONS } },
|
||||
},
|
||||
});
|
||||
|
||||
await render();
|
||||
|
||||
expect(screen.getByText('무통장 입금 안내')).toBeInTheDocument();
|
||||
expect(screen.getByText('배송 완료')).toBeInTheDocument();
|
||||
|
||||
// raw 키가 화면에 남아있으면 회귀
|
||||
expect(
|
||||
screen.queryByText(/notification_definitions\.types\.order_pending_deposit/),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/notification_definitions\.types\.order_delivered/),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('en: order_pending_deposit / order_delivered 가 라벨로 렌더된다 (raw 키 미노출)', async () => {
|
||||
registry = createMockComponentRegistryWithBasics();
|
||||
const { render, cleanup } = createLayoutTest(makeLayout(), {
|
||||
componentRegistry: registry as any,
|
||||
locale: 'en',
|
||||
translations: buildTranslations(settingsEn),
|
||||
initialData: {
|
||||
ecommerceNotificationDefinitions: { data: { data: DEFINITIONS } },
|
||||
},
|
||||
});
|
||||
|
||||
await render();
|
||||
|
||||
expect(screen.getByText('Bank Transfer Payment Guide')).toBeInTheDocument();
|
||||
expect(screen.getByText('Order Delivered')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByText(/notification_definitions\.types\.order_pending_deposit/),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/notification_definitions\.types\.order_delivered/),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('라벨 제거 시 raw 키가 노출됨 — 회귀 가드가 실제로 결함을 감지한다', async () => {
|
||||
// 이 테스트는 위 두 가드가 "우연히 통과"하는 것이 아님을 증명한다:
|
||||
// settings.json 에서 두 타입 라벨을 제거하면 화면에 raw 키가 그대로 노출된다.
|
||||
registry = createMockComponentRegistryWithBasics();
|
||||
const stripped = JSON.parse(JSON.stringify(settingsKo));
|
||||
delete stripped.notification_definitions.types.order_pending_deposit;
|
||||
delete stripped.notification_definitions.types.order_delivered;
|
||||
|
||||
const { render, cleanup } = createLayoutTest(makeLayout(), {
|
||||
componentRegistry: registry as any,
|
||||
locale: 'ko',
|
||||
translations: buildTranslations(stripped),
|
||||
initialData: {
|
||||
ecommerceNotificationDefinitions: { data: { data: DEFINITIONS } },
|
||||
},
|
||||
});
|
||||
|
||||
await render();
|
||||
|
||||
expect(screen.queryByText('무통장 입금 안내')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/notification_definitions\.types\.order_pending_deposit/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('settings.json(ko/en) 의 types 에 두 신규 타입 라벨이 존재한다', () => {
|
||||
const koTypes = (settingsKo as any).notification_definitions.types;
|
||||
const enTypes = (settingsEn as any).notification_definitions.types;
|
||||
|
||||
expect(koTypes.order_pending_deposit).toBe('무통장 입금 안내');
|
||||
expect(koTypes.order_delivered).toBe('배송 완료');
|
||||
expect(enTypes.order_pending_deposit).toBe('Bank Transfer Payment Guide');
|
||||
expect(enTypes.order_delivered).toBe('Order Delivered');
|
||||
});
|
||||
});
|
||||
@@ -447,7 +447,9 @@
|
||||
"save_error": "Failed to save notification settings.",
|
||||
"types": {
|
||||
"order_confirmed": "Order Confirmed",
|
||||
"order_pending_deposit": "Bank Transfer Payment Guide",
|
||||
"order_shipped": "Order Shipped",
|
||||
"order_delivered": "Order Delivered",
|
||||
"order_completed": "Purchase Completed",
|
||||
"order_cancelled": "Order Cancelled",
|
||||
"new_order_admin": "New Order (Admin)",
|
||||
|
||||
@@ -447,7 +447,9 @@
|
||||
"save_error": "알림 설정 저장에 실패했습니다.",
|
||||
"types": {
|
||||
"order_confirmed": "주문 확인",
|
||||
"order_pending_deposit": "무통장 입금 안내",
|
||||
"order_shipped": "배송 시작",
|
||||
"order_delivered": "배송 완료",
|
||||
"order_completed": "구매 확정",
|
||||
"order_cancelled": "주문 취소",
|
||||
"new_order_admin": "신규 주문 (관리자)",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"generated_at": "2026-07-10T12:05:27+00:00",
|
||||
"generated_at": "2026-07-10T20:22:38+00:00",
|
||||
"generator": "g7 vendor-bundle:build",
|
||||
"target": "module:sirsoft-ecommerce",
|
||||
"composer_json_sha256": "aa8f74063b7f9cd969cb4effb120e3c50bb944a4d3b2e16fae7c31bd8232fce8",
|
||||
"composer_json_sha256": "5195d789bc9edd0b5140c42aeedd7c87be67c7252b477a879ff90607b4787935",
|
||||
"composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d",
|
||||
"zip_sha256": "ef77515e4d920a52a8adba1e6d7e310389c6f3f459586d3cd5c24749f24d7fb0",
|
||||
"zip_sha256": "c1767e8eca61163667e9289a3963454bab92806ce4e8a3651777f1e1aea14081",
|
||||
"zip_size": 435549,
|
||||
"package_count": 1,
|
||||
"php_requirement": "^8.2",
|
||||
|
||||
Binary file not shown.
+184
-2
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace Tests\Unit\Extension\Vendor;
|
||||
|
||||
use App\Extension\Vendor\EnvironmentDetector;
|
||||
use App\Extension\Vendor\Exceptions\VendorInstallException;
|
||||
use App\Extension\Vendor\VendorBundler;
|
||||
use App\Extension\Vendor\VendorBundleResult;
|
||||
use App\Extension\Vendor\VendorIntegrityChecker;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
@@ -79,7 +81,7 @@ class VendorBundlerTest extends TestCase
|
||||
*
|
||||
* 테스트는 단일 testDir 을 소스/출력 동일하게 사용 (코어 케이스와 유사).
|
||||
*/
|
||||
private function invokeBuild(string $target, bool $force = false, ?string $sourcePath = null, ?string $outputPath = null): \App\Extension\Vendor\VendorBundleResult
|
||||
private function invokeBuild(string $target, bool $force = false, ?string $sourcePath = null, ?string $outputPath = null): VendorBundleResult
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->bundler);
|
||||
$method = $reflection->getMethod('build');
|
||||
@@ -239,6 +241,157 @@ class VendorBundlerTest extends TestCase
|
||||
$this->assertFileDoesNotExist($sourceDir.'/vendor-bundle.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 분리 구조에서 manifest 의 composer 해시는 **출력(_bundled)** 의 composer.json 기준이어야 합니다.
|
||||
*
|
||||
* 런타임 검증(VendorIntegrityChecker::verify)은 manifest 와 같은 디렉토리(_bundled 또는 그 복사본인
|
||||
* staging)의 composer.json 을 대조한다. 해시를 활성 디렉토리에서 계산하면, _bundled 의 composer.json
|
||||
* 만 변경된 상태(예: 버전 bump 직후)에서 manifest 가 영구히 불일치하여 module:update 가 막힌다.
|
||||
*/
|
||||
public function test_build_hashes_output_composer_json_not_source(): void
|
||||
{
|
||||
$sourceDir = $this->testDir.'/active';
|
||||
$outputDir = $this->testDir.'/bundled';
|
||||
File::ensureDirectoryExists($sourceDir);
|
||||
File::ensureDirectoryExists($outputDir);
|
||||
|
||||
// 활성 — 구버전 (아직 update 가 반영되지 않은 상태)
|
||||
File::put($sourceDir.'/composer.json', json_encode([
|
||||
'name' => 'test/project',
|
||||
'version' => '1.0.2',
|
||||
'require' => ['php' => '^8.2', 'test/lib' => '^1.0'],
|
||||
]));
|
||||
File::put($sourceDir.'/composer.lock', json_encode([
|
||||
'packages' => [
|
||||
['name' => 'test/lib', 'version' => '1.0.0', 'type' => 'library'],
|
||||
],
|
||||
]));
|
||||
|
||||
// _bundled — 신버전 (작업자가 version bump 한 상태)
|
||||
File::put($outputDir.'/composer.json', json_encode([
|
||||
'name' => 'test/project',
|
||||
'version' => '1.0.3',
|
||||
'require' => ['php' => '^8.2', 'test/lib' => '^1.0'],
|
||||
]));
|
||||
|
||||
$this->invokeBuild('test:split-hash', sourcePath: $sourceDir, outputPath: $outputDir);
|
||||
|
||||
$manifest = json_decode(File::get($outputDir.'/vendor-bundle.json'), true);
|
||||
|
||||
$this->assertSame(
|
||||
$this->checker->computeFileHash($outputDir.'/composer.json'),
|
||||
$manifest['composer_json_sha256'],
|
||||
'manifest 의 composer_json_sha256 은 출력(_bundled) 의 composer.json 해시여야 합니다.'
|
||||
);
|
||||
|
||||
// 검증자가 outputDir 을 sourceDir 로 verify 할 때 통과해야 한다 (module:update 경로 재현)
|
||||
$this->assertTrue(
|
||||
$this->checker->verify($outputDir)->valid,
|
||||
'build 직후 _bundled 를 verify 하면 무결성 검증을 통과해야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 분리 구조에서 isStale 은 **출력(_bundled)** 의 composer.json 변경을 감지해야 합니다.
|
||||
*
|
||||
* 활성 디렉토리만 검사하면 _bundled 의 stale 을 up-to-date 로 오보하여, --check 가 재빌드 필요를
|
||||
* 놓치고 이후 module:update 가 무결성 오류로 실패한다.
|
||||
*/
|
||||
public function test_is_stale_detects_output_composer_json_change(): void
|
||||
{
|
||||
$sourceDir = $this->testDir.'/active';
|
||||
$outputDir = $this->testDir.'/bundled';
|
||||
File::ensureDirectoryExists($sourceDir);
|
||||
File::ensureDirectoryExists($outputDir);
|
||||
|
||||
$composerJson = json_encode([
|
||||
'name' => 'test/project',
|
||||
'version' => '1.0.2',
|
||||
'require' => ['php' => '^8.2', 'test/lib' => '^1.0'],
|
||||
]);
|
||||
File::put($sourceDir.'/composer.json', $composerJson);
|
||||
File::put($sourceDir.'/composer.lock', json_encode([
|
||||
'packages' => [
|
||||
['name' => 'test/lib', 'version' => '1.0.0', 'type' => 'library'],
|
||||
],
|
||||
]));
|
||||
File::put($outputDir.'/composer.json', $composerJson);
|
||||
|
||||
$this->invokeBuild('test:split-stale', sourcePath: $sourceDir, outputPath: $outputDir);
|
||||
$this->assertFalse(
|
||||
$this->bundler->isStale($sourceDir, $outputDir),
|
||||
'빌드 직후에는 stale 이 아니어야 합니다.'
|
||||
);
|
||||
|
||||
// _bundled 의 composer.json 만 변경 (활성은 그대로) — 버전 bump 시나리오
|
||||
File::put($outputDir.'/composer.json', json_encode([
|
||||
'name' => 'test/project',
|
||||
'version' => '1.0.3',
|
||||
'require' => ['php' => '^8.2', 'test/lib' => '^1.0'],
|
||||
]));
|
||||
|
||||
$this->assertTrue(
|
||||
$this->bundler->isStale($sourceDir, $outputDir),
|
||||
'_bundled 의 composer.json 이 변경되면 stale 로 판정해야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 재빌드가 실패하면 기존 번들 파일이 보존되어야 합니다.
|
||||
*
|
||||
* 기존 zip/manifest 를 staging 빌드 이전에 삭제하면, composer install 실패 시
|
||||
* 새 파일이 생성되지 못한 채 원본만 유실되어 다음 module:update 가 번들 부재로
|
||||
* 실패한다 (Windows 등에서 composer 실행이 불안정할 때 재현). 삭제는 재생성
|
||||
* 성공이 확정된 뒤에 이뤄져야 한다.
|
||||
*/
|
||||
public function test_failed_rebuild_preserves_existing_bundle(): void
|
||||
{
|
||||
$this->createFakeProject();
|
||||
|
||||
// 1차 빌드 성공 — 정상 번들 생성
|
||||
$this->invokeBuild('test:project');
|
||||
$this->assertFileExists($this->testDir.'/vendor-bundle.zip');
|
||||
$this->assertFileExists($this->testDir.'/vendor-bundle.json');
|
||||
|
||||
$originalZip = File::get($this->testDir.'/vendor-bundle.zip');
|
||||
$originalManifest = File::get($this->testDir.'/vendor-bundle.json');
|
||||
|
||||
// composer install 이 실패하도록 러너 교체
|
||||
$this->bundler->setComposerInstallRunner(function (): void {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'bundle_build_composer_failed',
|
||||
context: ['exit' => 1, 'message' => 'simulated composer failure'],
|
||||
);
|
||||
});
|
||||
|
||||
// 2차 빌드(force) — 실패해야 하지만 기존 번들은 보존되어야 함
|
||||
try {
|
||||
$this->invokeBuild('test:project', force: true);
|
||||
$this->fail('composer install 실패 시 빌드는 예외를 던져야 합니다.');
|
||||
} catch (VendorInstallException) {
|
||||
// 예상된 실패
|
||||
}
|
||||
|
||||
$this->assertFileExists(
|
||||
$this->testDir.'/vendor-bundle.zip',
|
||||
'재빌드 실패 시 기존 zip 이 보존되어야 합니다.'
|
||||
);
|
||||
$this->assertFileExists(
|
||||
$this->testDir.'/vendor-bundle.json',
|
||||
'재빌드 실패 시 기존 manifest 가 보존되어야 합니다.'
|
||||
);
|
||||
$this->assertSame(
|
||||
$originalZip,
|
||||
File::get($this->testDir.'/vendor-bundle.zip'),
|
||||
'보존된 zip 은 원본과 동일해야 합니다.'
|
||||
);
|
||||
$this->assertSame(
|
||||
$originalManifest,
|
||||
File::get($this->testDir.'/vendor-bundle.json'),
|
||||
'보존된 manifest 는 원본과 동일해야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 의존성 없는 확장은 isStale 이 항상 false 를 반환해야 합니다.
|
||||
*/
|
||||
@@ -306,7 +459,7 @@ class VendorBundlerTest extends TestCase
|
||||
*/
|
||||
public function test_build_throws_when_composer_not_available(): void
|
||||
{
|
||||
$detector = \Mockery::mock(\App\Extension\Vendor\EnvironmentDetector::class);
|
||||
$detector = \Mockery::mock(EnvironmentDetector::class);
|
||||
$detector->shouldReceive('canExecuteComposer')->andReturn(false);
|
||||
|
||||
$bundler = new VendorBundler($this->checker, $detector);
|
||||
@@ -321,4 +474,33 @@ class VendorBundlerTest extends TestCase
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($bundler, $this->testDir, $this->testDir, 'test:no-composer', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* composer install 의 stdout/stderr 는 파이프가 아닌 파일 descriptor 로 열려야 한다.
|
||||
*
|
||||
* (Windows 회귀 가드) stdout/stderr 를 파이프로 열면 composer autoload dump 단계의
|
||||
* async `cmd.exe` 손자 프로세스가 부모 파이프 핸들을 상속·점유해 proc_close 가
|
||||
* 자식 완료 신호를 받지 못하고 무한 대기(행)한다. 실측: 파이프=행, 파일=140s 완주.
|
||||
* descriptor 구조를 파이프로 되돌리면 이 테스트가 red 로 회귀를 차단한다.
|
||||
*/
|
||||
public function test_composer_output_descriptors_use_files_not_pipes(): void
|
||||
{
|
||||
$descriptors = VendorBundler::composerOutputDescriptors('/tmp/out.log', '/tmp/err.log');
|
||||
|
||||
// stdin 은 파이프(입력 없음 즉시 닫힘) 로 유지.
|
||||
$this->assertSame(['pipe', 'r'], $descriptors[0]);
|
||||
|
||||
// stdout/stderr 는 파일 descriptor 여야 한다 (파이프 금지 — 손자 핸들 상속 차단).
|
||||
$this->assertSame('file', $descriptors[1][0], 'stdout 은 파일 descriptor 여야 함 (파이프 시 Windows 행 회귀)');
|
||||
$this->assertSame('/tmp/out.log', $descriptors[1][1]);
|
||||
$this->assertSame('w', $descriptors[1][2]);
|
||||
|
||||
$this->assertSame('file', $descriptors[2][0], 'stderr 은 파일 descriptor 여야 함 (파이프 시 Windows 행 회귀)');
|
||||
$this->assertSame('/tmp/err.log', $descriptors[2][1]);
|
||||
$this->assertSame('w', $descriptors[2][2]);
|
||||
|
||||
// 어떤 채널도 'pipe','w' 로 열리지 않아야 함 (손자 상속 가능한 열린 파이프 부재).
|
||||
$this->assertNotSame('pipe', $descriptors[1][0]);
|
||||
$this->assertNotSame('pipe', $descriptors[2][0]);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"generated_at": "2026-06-30T04:00:54+00:00",
|
||||
"generated_at": "2026-07-10T21:33:36+00:00",
|
||||
"generator": "g7 vendor-bundle:build",
|
||||
"target": "core",
|
||||
"composer_json_sha256": "76294945639dbbe4b6d054c0aea4b78377a55bcdffcd1c9136beb294bc7cfc90",
|
||||
"composer_lock_sha256": "b4d173c40088a78c9fe42b3ca6d23436bee6cc850a17bb653cbc1aca59ca3a81",
|
||||
"zip_sha256": "0a11e8d918f58465d231bfe2ba84a0d9f749112ca4b79e19ac0224145abc0ef1",
|
||||
"zip_sha256": "d5d21fdd930350332d2d58e5ab386ba9203795f72f7767eb3c214d4702152245",
|
||||
"zip_size": 14750349,
|
||||
"package_count": 104,
|
||||
"php_requirement": "^8.2",
|
||||
"g7_version": "7.0.0",
|
||||
"g7_version": "7.0.3",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user