Files
Gnuboard7/tests/Feature/Installer/DevVendorDetectionTest.php
T
HeuJung ffab451b4a fix(core,installer): dev vendor 설치본의 코어 업데이트 중단 수정 — stale 패키지 매니페스트 3계층
Laravel PackageManifest 는 bootstrap/cache/packages.php 가 있으면 stale 여부를 검사하지
않고 그대로 읽어 등재된 provider 를 new 한다. 코어 업데이트는 Step 6/8 에서 vendor 를
--no-dev 로 교체하지만 그 파일은 Step 11 까지 이전 설치본의 것이 남으므로, 옵션 없는
`composer install` 로 깔린 사이트에서는 Step 10 spawn 자식이 새 vendor 에 없는 provider 를
찾다 부팅 단계에서 죽는다. 부팅 전이라 앱 로그에 흔적이 없고 부모에게는 자식의 비정상
종료로만 보여, 운영자에게는 「Class ... not found」 와 수동 재개 안내만 남는다.
(sir.kr 커뮤니티 제보, 7.0.9 → 7.0.10)

3계층으로 막는다.

 1. 부모 — spawn 직전 PackageManifestCacheHelper::clear
 2. 자식 — bootstrap/app.php 가 G7_UPDATE_IN_PROGRESS 를 보고 스스로 정리한다.
 이미 배포된 7.0.9·7.0.10 부모는 고칠 수 없으므로 그 아래에서 도는 신버전
 자식의 유일한 방어다. App\ 클래스를 참조하지 않고 실패는 무시한다.
 3. 범위 — CoreVersionChecker 의 env APP_VERSION 우선을 CoreUpdateContext 트리 안으로
 축소한다. 업데이트 전에 뜬 artisan serve·큐 워커가 옛 값을 물고 확장을
 incompatible_core 로 끄던 경로를 닫는다(관리자 템플릿이 대상이면 복구 UI
 자체에 도달할 수 없다).

업데이트 트리 판정은 App\Support\CoreUpdateContext 가 단독 소유하고
CoreServiceProvider::isCoreUpdateInProgress 는 위임으로 남는다. bootstrap/app.php 의
복제본은 부팅 전이라 불가피한 예외이며, 두 조건의 동형성을 테스트가 단언한다.

실측 중 드러난 결함 2건을 함께 고쳤다.

 - ConfigCacheHelper::withPreservedContainer 가 파사드 애플리케이션을 되돌리지 않아
 Step 11 이 `Target class [command.tinker] does not exist` 로 실패·롤백했다.
 - updateVersionInEnv 가 프로세스 환경을 갱신하지 않아 config 캐시에 이전 버전이 구워졌다
 (Laravel env 저장소가 불변이라 재부팅으로도 덮이지 않는다).

인스톨러는 재사용 vendor 의 개발용 패키지를 installed.json 으로 감지해 설치 환경 확인
카드·설치 로그로 알리되 설치를 차단하지 않고( 결정 D1), 재사용 경로에서도 컴파일 캐시를
정리한다. 실행되는 명령만이 아니라 실패 시 안내하는 수동 명령까지 --no-dev 로 맞췄다.
코어 업데이트 완료·핸드오프·단독 재개 사후 단계에서 queue:restart 신호를 보낸다(D3).

코어 7.0.10 → 7.0.11.
2026-09-08 09:46:57 +09:00

326 lines
14 KiB
PHP

<?php
namespace Tests\Feature\Installer;
use App\Support\ComposerInstallInfo;
use Illuminate\Support\Facades\File;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 인스톨러 vendor 재사용 경로의 dev 패키지 감지·경고·캐시 정리 회귀 테스트 (dev-g7 #658).
*
* 배경: `INSTALL.md`/`README.md` 가 인스톨러 **전에** `composer install`(옵션 없음)을 지시했고,
* 인스톨러는 `vendor/autoload.php` + `composer.lock` 이 있으면 composer 단계를 통째로 건너뛰며
* 그 vendor 를 아무 표시 없이 그대로 썼다. 그 결과 운영 사이트에 개발용 패키지가 남고,
* 이후 코어 업데이트가 vendor 를 `--no-dev` 로 교체할 때 이전 매니페스트에만 남은 provider 를
* 찾다 부팅이 깨졌다(sir.kr 제보, 7.0.9 → 7.0.10).
*
* `task-runner.php` 는 진입과 함께 SSE 를 실행하는 파일이라 테스트에서 include 할 수 없다.
* 그래서 순수 함수(`detectDevVendorInstall`)는 직접 호출해 검증하고, 분기의 배치·순서는
* 소스 문자열 계약으로 잠근다 — `BootstrapCacheCleanupTest` 와 같은 방식이다.
*/
class DevVendorDetectionTest extends TestCase
{
private string $testBase;
private string $vendorPath;
protected function setUp(): void
{
parent::setUp();
$this->testBase = storage_path('app/test-dev-vendor-'.uniqid());
$this->vendorPath = $this->testBase.'/vendor';
File::ensureDirectoryExists($this->vendorPath.'/composer');
require_once base_path('public/install/includes/vendor-bundle-installer.php');
}
protected function tearDown(): void
{
if (File::isDirectory($this->testBase)) {
File::deleteDirectory($this->testBase);
}
parent::tearDown();
}
/**
* dev 설치본을 감지한다.
*
* @effects installer_detects_dev_vendor_from_installed_json
*/
#[Test]
public function detect_dev_vendor_install_은_dev_설치본을_감지한다(): void
{
$this->writeInstalledJson(['packages' => [], 'dev' => true, 'dev-package-names' => ['laravel/boost', 'laravel/mcp']]);
$info = detectDevVendorInstall($this->testBase);
$this->assertTrue($info['dev']);
$this->assertSame(['laravel/boost', 'laravel/mcp'], $info['packages']);
}
/**
* `--no-dev` 설치본은 경고 대상이 아니다.
*/
#[Test]
public function detect_dev_vendor_install_은_운영용_설치본을_경고하지_않는다(): void
{
$this->writeInstalledJson(['packages' => [], 'dev' => false, 'dev-package-names' => []]);
$info = detectDevVendorInstall($this->testBase);
$this->assertFalse($info['dev']);
$this->assertSame([], $info['packages']);
}
/**
* installed.json 이 없으면 판정 불가(null) — 경고도 차단도 하지 않는다.
*
* @effects installer_treats_missing_installed_json_as_unknown
*/
#[Test]
public function detect_dev_vendor_install_은_installed_json_부재를_판정_불가로_다룬다(): void
{
$info = detectDevVendorInstall($this->testBase);
$this->assertNull($info['dev']);
$this->assertSame([], $info['packages']);
}
/**
* 인스톨러 shim 과 코어 판정기가 같은 답을 낸다 — 갈라지면 화면과 로그가 어긋난다.
*
* @effects installer_shim_matches_core_composer_install_info
*/
#[Test]
public function 인스톨러_shim_과_코어_판정기의_결과가_같다(): void
{
foreach ([
['packages' => [], 'dev' => true, 'dev-package-names' => ['laravel/mcp']],
['packages' => [], 'dev' => false, 'dev-package-names' => []],
] as $payload) {
$this->writeInstalledJson($payload);
$this->assertSame(
ComposerInstallInfo::inspect($this->vendorPath),
detectDevVendorInstall($this->testBase),
'shim 은 코어 판정기에 위임하기만 해야 한다'
);
}
}
/**
* 재사용 분기가 dev 를 감지해 경고하되 설치를 계속한다 (소스 계약).
*
* 감지·경고·"설치 계속" 셋이 한 분기 안에 있어야 한다 — 차단으로 바뀌면 PO 결정(D1) 위반이다.
*
* @effects installer_warns_and_keeps_dev_vendor
*/
#[Test]
public function 재사용_분기가_dev_를_감지해_경고하고_설치를_계속한다(): void
{
$source = File::get(base_path('public/install/includes/task-runner.php'));
$this->assertStringContainsString('detectDevVendorInstall(BASE_PATH)', $source, '재사용 분기가 dev 판정을 호출해야 한다');
$this->assertStringContainsString('log_composer_dev_packages_detected', $source, '감지 사실을 설치 로그에 남겨야 한다');
$this->assertStringContainsString('warning_composer_dev_packages_kept', $source, '정리 명령 안내 경고를 남겨야 한다');
$branch = $this->reuseBranchSource($source);
$this->assertStringContainsString("return ['success' => true]", $branch, '경고는 설치를 차단하지 않는다 (D1)');
}
/**
* 재사용 경로도 컴파일 캐시를 정리하고, 그 정리가 완료 표시보다 앞선다 (소스 계약).
*
* 종전에는 정리 없이 return 해, `key_generate` 가 이미 완료된 재개 설치에서는 어디서도
* 정리되지 않았다.
*
* @effects installer_clears_compiled_cache_on_reuse_path
*/
#[Test]
public function 재사용_분기가_컴파일_캐시를_정리하고_완료_표시보다_앞선다(): void
{
$branch = $this->reuseBranchSource(File::get(base_path('public/install/includes/task-runner.php')));
$clearPos = strpos($branch, 'clearLaravelCompiledCache(BASE_PATH)');
$completePos = strpos($branch, "markTaskCompleted('composer_install')");
$this->assertNotFalse($clearPos, '재사용 분기가 컴파일 캐시를 정리해야 한다');
$this->assertNotFalse($completePos, '전제: 재사용 분기가 작업 완료를 표시한다');
$this->assertLessThan($completePos, $clearPos, '캐시 정리는 완료 표시보다 앞서야 한다');
}
/**
* 신규 언어 키가 ko/en 양쪽에 있고 치환 자리가 보존되어 있다.
*
* 한쪽만 있으면 그 로케일에서 키 문자열이 그대로 화면에 노출되고, 치환 자리가 빠지면
* 건수·패키지 목록이 사라진 문장이 나간다 — 둘 다 오류 없이 통과한다.
*
* @effects installer_lang_keys_exist_in_both_locales
*/
#[Test]
public function 신규_언어_키가_ko_와_en_양쪽에_있고_치환_자리를_보존한다(): void
{
$ko = require base_path('public/install/lang/ko.php');
$en = require base_path('public/install/lang/en.php');
$keys = [
'vendor_dev_packages',
'vendor_dev_packages_none_short',
'vendor_dev_packages_none',
'vendor_dev_packages_detected_short',
'vendor_dev_packages_detected_warning',
'vendor_dev_packages_unknown',
'vendor_dev_packages_no_vendor',
'log_composer_dev_packages_detected',
'warning_composer_dev_packages_kept',
];
foreach ($keys as $key) {
$this->assertArrayHasKey($key, $ko, "ko 에 {$key} 가 있어야 한다");
$this->assertArrayHasKey($key, $en, "en 에 {$key} 가 있어야 한다");
$this->assertNotSame('', trim((string) $ko[$key]), "ko 의 {$key} 는 비어 있으면 안 된다");
$this->assertNotSame('', trim((string) $en[$key]), "en 의 {$key} 는 비어 있으면 안 된다");
}
foreach (['vendor_dev_packages_detected_short', 'vendor_dev_packages_detected_warning'] as $key) {
$this->assertStringContainsString(':count', $ko[$key], "ko 의 {$key} 는 :count 치환 자리를 가져야 한다");
$this->assertStringContainsString(':count', $en[$key], "en 의 {$key} 는 :count 치환 자리를 가져야 한다");
}
foreach ([':count', ':packages'] as $placeholder) {
$this->assertStringContainsString($placeholder, $ko['log_composer_dev_packages_detected']);
$this->assertStringContainsString($placeholder, $en['log_composer_dev_packages_detected']);
}
}
/**
* `installComposerDependenciesSSE()` 안의 vendor 재사용 분기 본문을 잘라냅니다.
*
* @param string $source task-runner.php 전문
* @return string 재사용 분기 블록
*/
private function reuseBranchSource(string $source): string
{
$start = strpos($source, 'if ($vendorExists && $lockExists) {');
$this->assertNotFalse($start, '전제: vendor 재사용 분기가 존재한다');
$end = strpos($source, 'if ($vendorExists && ! $lockExists) {', $start);
$this->assertNotFalse($end, '전제: 재사용 분기 다음에 lock 부재 분기가 온다');
return substr($source, $start, $end - $start);
}
/**
* 인스톨러·코어가 다루는 composer install 명령은 전부 `--no-dev` 를 갖는다.
*
* 실행되는 명령만이 아니라 **실패 시 운영자에게 안내하는 명령**도 대상이다. 안내를 옵션 없이
* 따라 하면 개발용(require-dev) 패키지가 섞인 vendor 가 만들어지고, 그 상태는 이후 코어
* 업데이트가 vendor 를 교체할 때 이전 패키지 매니페스트와 어긋나 부팅을 깨뜨린다 — 이 저장소가
* 막으려는 바로 그 상태다. 실제로 `rollback-functions.php` 의 안내가 그렇게 어긋나 있었다.
*
* 모집단은 소스에서 정규식으로 파생한다 — **파일 목록도 파생 대상이다.** 검사할 파일을 손으로
* 열거하면 네 번째 파일이 안내 명령을 갖게 돼도 케이스 없이 통과한다(그 형태가 이 결함을
* 처음 놓친 원인이었다: 리터럴 `"composer install"` 만 보는 파생식이 `{$composer} install` 을
* 못 봤다). 그래서 디렉토리 트리를 훑어 매칭되는 파일 자체를 찾는다. 파생이 비면 그 자체로
* 실패시켜 "검사했으나 0건" 과 "보지 않음" 을 구분한다.
*
* @effects installer_composer_commands_all_carry_no_dev
*/
#[Test]
public function 안내하는_것까지_포함해_모든_composer_install_명령이_no_dev_를_갖는다(): void
{
$found = [];
foreach ($this->phpSourcesUnder(['public/install', 'app', 'upgrades', 'bootstrap']) as $relative) {
$source = File::get(base_path($relative));
preg_match_all(
'/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?[^\n]{0,15}install [^\n\'"]*/',
$source,
$matches,
PREG_SET_ORDER
);
foreach ($matches as $match) {
// 변수명에 composer 가 들어간 것만 — 정규식 안에서 걸러내면 첫 문자 소비 때문에
// `$composer` 자신이 매칭에서 빠진다.
if (stripos($match[1], 'composer') === false) {
continue;
}
$found[] = [$relative, $match[0]];
}
}
$this->assertNotEmpty(
$found,
'composer install 명령을 하나도 찾지 못했다 — 파생식이 소스 형태를 따라가지 못하면 이 검사는 공허하게 통과한다'
);
foreach ($found as [$relative, $command]) {
$this->assertStringContainsString(
'--no-dev',
$command,
"{$relative} 의 composer install 명령에 --no-dev 가 있어야 한다: {$command}"
);
}
}
/**
* 주어진 디렉토리 트리 아래의 PHP 소스 파일 경로를 저장소에서 파생합니다.
*
* 검사 대상 파일을 손으로 열거하지 않기 위한 것이다 — 새 파일이 composer 명령을 갖게 되면
* 목록을 고치지 않아도 모집단에 들어온다. `vendor/` 는 제3자 코드라 제외한다.
*
* @param array<int, string> $roots base_path 기준 상대 디렉토리
* @return array<int, string> base_path 기준 상대 파일 경로 (POSIX 구분자)
*/
private function phpSourcesUnder(array $roots): array
{
$files = [];
foreach ($roots as $root) {
$absoluteRoot = base_path($root);
if (! is_dir($absoluteRoot)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($absoluteRoot, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (! $file->isFile() || strtolower($file->getExtension()) !== 'php') {
continue;
}
$relative = str_replace('\\', '/', substr($file->getPathname(), strlen(base_path()) + 1));
if (str_contains($relative, '/vendor/')) {
continue;
}
$files[] = $relative;
}
}
sort($files);
return $files;
}
/**
* installed.json 픽스처를 씁니다.
*
* @param array<string, mixed> $payload JSON 으로 직렬화할 내용
*/
private function writeInstalledJson(array $payload): void
{
File::put($this->vendorPath.'/'.ComposerInstallInfo::INSTALLED_JSON, json_encode($payload));
}
}