Files
Gnuboard7/tests/bootstrap.php
T
HeuJung 0c4f52fc96 fix(core): 업데이트 트리 argv 판정을 명령줄 SAPI 로 한정하고 매니페스트 삭제 실패를 로그에 남김
7.0.11 인스톨러·코어 업데이트 변경(e60a82d73)에 대해 과거 회귀 22건을 부류별로 대조한
결과, 신규 노출면 1건과 테스트 위생 1건이 나와 인터뷰 결정대로 조치했다.

1. argv 채널의 SAPI 게이트 — CGI/FPM 은 register_argc_argv=On 이면 $_SERVER['argv'] 를
 쿼리스트링을 '+' 로 쪼개 채우므로(`GET /?x+core:update` → argv[1]==='core:update', php-cgi
 실측) 비인증 웹 요청이 업데이트 트리로 판정되어 bootstrap/app.php 자가 치유가 요청마다
 패키지 매니페스트를 지우고 다시 만들었다. CoreUpdateContext 와 bootstrap/app.php 복제본
 모두 argv 를 cli·phpdbg 에서만 읽는다. env 플래그 채널은 웹에서 주입할 수 없으므로 그대로
 두어 웹 요청 안에서 시작하는 업데이트 흐름(7.1.0)에 영향이 없다. 동형성 테스트에 SAPI 축을
 더했다.

2. 매니페스트 삭제 실패 기록 — PackageManifestCacheHelper::clear 가 지우지 못한 파일의
 경로를 돌려주고, spawn 직전 호출부가 업그레이드 로그·콘솔에 경고로 남긴다. 권한·소유권
 불일치면 자식의 자가 치유도 같은 이유로 실패해 증상은 제보와 같은 「Class not found」 인데,
 이 경고가 원인이 권한이라는 유일한 흔적이다.

3. 테스트 격리 — tests/bootstrap.php 가 APP_PACKAGES_CACHE/APP_SERVICES_CACHE 를 테스트
 전용 경로로 돌린다. proc_open 으로 자식을 띄우는 기존 테스트 2종의 자식이 개발 클론의
 실제 bootstrap/cache 매니페스트를 지우고 다시 쓰던 것(stat 실측)을 부모·자식 함께 막는다.

관리자 [시스템 최적화] 경로(withPreservedContainer 파사드 복원의 미실측 형제 호출처)는
임시 설치본에서 API 로 실측했다 — 200, 설정·라우트 캐시 재생성, 후속 요청 200, 로그 오류 0.

4. 트러블슈팅 사례 ↔ 회귀 테스트 앵커 계약 — 신규 사례는 헤딩에 <!-- case:{영역}-{번호} -->
 앵커를 달고 같은 문자열을 그 사례를 잠그는 회귀 테스트에도 남겨야 한다. 사례 번호는
 문서마다 1부터 재시작하고 병합으로 중복되므로(이번 리베이스에서도 우리 사례가 develop 과
 같은 29 였다가 31 로 밀렸다), 개수만 대조하면 다른 사례를 덮는 테스트도 초록이 된다.

 그런데 판정기 check-troubleshooting-test-coverage.cjs 를 부르는 지점이 저장소에 하나도
 없었다 — 스크립트 자체 주석에만 실행법이 적혀 있어 아무도 부르지 않으면 영원히 돌지
 않았고, 그 사이 위반이 9건 쌓였다(backend 26~31, cache 17~19). 돌지 않는 대조는 아무것도
 잠그지 못하므로 위반 해소와 실행 지점 부여를 함께 한다.

 9건 전부에 앵커를 부착하고(각 사례가 선언한 회귀 테스트 중 가장 구체적인 파일에 배치,
 한 파일이 두 사례에 선언된 경우는 갈라 배치), stop-guard 7.2 에 앵커 계약 + 미커버
 baseline ratchet 두 축으로 등록했다. 트러블슈팅 사례 추가 프로토콜에 6단계를
 더하고 coverage 에 troubleshooting-case-anchor-contract(manual-only, 전용 판정기 위임)를
 등재했다. 판정기 종료코드 1 → 0, 미커버 건수는 전 문서 baseline 그대로다.
2026-09-08 10:42:19 +09:00

589 lines
26 KiB
PHP

<?php
use Composer\Autoload\ClassLoader;
/**
* PHPUnit 테스트용 부트스트랩 파일
*
* PHPUnit 테스트 실행 시 모듈/플러그인 오토로드를 등록합니다.
* phpunit.xml의 bootstrap 속성에서 이 파일을 지정합니다.
*/
/*
|--------------------------------------------------------------------------
| .env.testing 존재 확인 (프로덕션 DB 오염 방지)
|--------------------------------------------------------------------------
|
| .env.testing 파일이 없으면 Laravel이 .env(프로덕션)를 사용하여
| 테스트가 프로덕션 DB에서 실행될 위험이 있습니다.
| .env.testing.example을 복사하여 .env.testing을 생성하세요.
|
*/
if (! file_exists(__DIR__.'/../.env.testing')) {
fwrite(STDERR, "\n".str_repeat('=', 60)."\n");
fwrite(STDERR, " ERROR: .env.testing 파일이 없습니다.\n");
fwrite(STDERR, " .env.testing.example을 복사하여 생성하세요:\n\n");
fwrite(STDERR, " cp .env.testing.example .env.testing\n\n");
fwrite(STDERR, " 그 후 DB 접속 정보와 APP_KEY를 설정하세요.\n");
fwrite(STDERR, str_repeat('=', 60)."\n\n");
exit(1);
}
/*
|--------------------------------------------------------------------------
| 프로덕션 DB 오염 방지 가드 (테스트 vs 프로덕션 DB 이름 충돌 차단)
|--------------------------------------------------------------------------
|
| phpunit.xml 의 `DB_*_DATABASE` 하드코딩을 제거하고 `.env.testing` 을 SSoT 로
| 삼았으므로, 실수로 `.env.testing` 의 DB 이름을 `.env` (프로덕션) 과 동일하게
| 설정할 경우 테스트가 프로덕션 DB 를 파괴할 수 있다.
|
| 여기서 양쪽의 DB_WRITE_DATABASE 를 비교하여 동일하면 즉시 중단한다.
|
*/
$parseDbName = static function (string $envFile): ?string {
if (! file_exists($envFile)) {
return null;
}
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
if (preg_match('/^DB_WRITE_DATABASE\s*=\s*(.+)$/', $line, $m)) {
// 따옴표 제거
return trim($m[1], "\"' \t");
}
}
return null;
};
$prodDbName = $parseDbName(__DIR__.'/../.env');
$testDbName = $parseDbName(__DIR__.'/../.env.testing');
if ($prodDbName !== null && $testDbName !== null && $prodDbName === $testDbName) {
fwrite(STDERR, "\n".str_repeat('=', 60)."\n");
fwrite(STDERR, " ERROR: 프로덕션 DB 오염 위험 — 테스트 중단.\n\n");
fwrite(STDERR, " .env 와 .env.testing 의 DB_WRITE_DATABASE 가 동일합니다: {$prodDbName}\n\n");
fwrite(STDERR, " 테스트용 별도 DB 를 사용하도록 .env.testing 을 수정하세요.\n");
fwrite(STDERR, " (예: DB_WRITE_DATABASE={$prodDbName}_testing)\n");
fwrite(STDERR, str_repeat('=', 60)."\n\n");
exit(1);
}
/*
|--------------------------------------------------------------------------
| 개발자 .env 보호 안전망 (테스트가 삭제/변조해도 원상 복구)
|--------------------------------------------------------------------------
|
| 일부 업그레이드/인스톨러 테스트는 실제 프로젝트 루트의 `.env` 를 삭제·재생성·머지하고
| 자신의 tearDown 에서 되돌린다. 그런데 그 테스트가 fatal 로 죽으면 tearDown 이 실행되지
| 않아 개발자의 `.env` 가 사라진 채 남는다. `.env` 는 Git 추적 대상이 아니라 복구 수단이
| 없고, APP_KEY 를 잃으면 기존 암호화 데이터·세션이 전부 무효가 된다.
|
| 실제로 그렇게 유실된 적이 있어(2026-07-21), 개별 테스트의 복원 로직에 의존하지 않고
| 프로세스 수준에서 한 번 더 막는다. 스냅샷은 메모리에만 두고, 셧다운 시점에 내용이
| 달라졌거나 파일이 사라졌으면 바이트 단위로 되돌린다. register_shutdown_function 은
| fatal error 에서도 실행되므로 개별 tearDown 보다 넓은 범위를 덮는다.
|
| 각 테스트가 자기 fixture 를 임시 base path 로 격리하는 것이 근본 해법이며, 이 안전망은
| 그 격리가 누락되거나 새 테스트가 같은 실수를 반복할 때를 대비한 최후 방어선이다.
|
*/
$realEnvPath = __DIR__.'/../.env';
$realEnvSnapshot = file_exists($realEnvPath) ? file_get_contents($realEnvPath) : null;
if ($realEnvSnapshot !== false && $realEnvSnapshot !== null) {
register_shutdown_function(static function () use ($realEnvPath, $realEnvSnapshot): void {
$current = file_exists($realEnvPath) ? file_get_contents($realEnvPath) : null;
if ($current === $realEnvSnapshot) {
return;
}
if (@file_put_contents($realEnvPath, $realEnvSnapshot) === false) {
fwrite(STDERR, "\n[테스트 안전망] .env 복원 실패 — 수동 확인 필요: {$realEnvPath}\n");
return;
}
fwrite(STDERR, "\n[테스트 안전망] 테스트가 변경한 .env 를 실행 전 상태로 복원했습니다.\n");
});
}
/*
|--------------------------------------------------------------------------
| 임시 upgrade 스텝 파일 잔재 정리
|--------------------------------------------------------------------------
|
| 업그레이드 커맨드 테스트는 실제 `upgrades/` 에 임시 스텝 파일을 쓴다 — 그 디렉토리를
| 스캔해야 스텝 발견 로직이 동작하기 때문이다. 각 테스트가 tearDown 에서 지우지만,
| 스위트가 fatal 로 중단되면 개발 체크아웃에 정체불명의 upgrade 스텝이 남는다.
|
| 파일명에 `_test_` 가 들어가는 것은 이 임시 파일들뿐이므로(실제 스텝은 `Upgrade_7_0_4`
| 처럼 버전만 갖는다) 그 패턴만 셧다운 시점에 쓸어낸다.
|
*/
register_shutdown_function(static function (): void {
foreach (glob(__DIR__.'/../upgrades/Upgrade_*_test_*.php') ?: [] as $leftover) {
@unlink($leftover);
}
});
/*
|--------------------------------------------------------------------------
| Config 캐시 삭제 (테스트 환경 보장)
|--------------------------------------------------------------------------
|
| Laravel은 config 캐시 파일이 존재하면 환경 변수를 무시하고 캐시된 값을 사용합니다.
| 테스트 환경(APP_ENV=testing)이 올바르게 적용되도록 캐시 파일을 삭제합니다.
|
*/
$configCacheFile = __DIR__.'/../bootstrap/cache/config.php';
if (file_exists($configCacheFile)) {
unlink($configCacheFile);
}
/*
|--------------------------------------------------------------------------
| 라우트 캐시 격리 (테스트 환경 보장)
|--------------------------------------------------------------------------
|
| 라우트 캐시가 있으면 `RouteServiceProvider::boot()` 이 캐시 로드로 분기해 **라우트 파일이
| 아예 실행되지 않는다.** 그러면 테스트가 선언한 확장 allowlist(`requiredExtensions`)가
| 라우트 축에서 통째로 무력화된다 — core-only 테스트인데도 굽던 시점에 활성이던 모든 확장의
| 라우트가 등록된 채로 부팅한다. 실패는 그 사실을 단언하는 테스트에서만 드러나고, 나머지
| 테스트는 격리가 깨진 줄 모르는 채 통과한다.
|
| 라우트 캐시는 확장 설치·활성화·업데이트마다 `RouteCacheHelper::rebuild()` 가 다시 굽기
| 때문에, 개발 머신에서는 사실상 항상 존재한다.
|
| 삭제하지 않고 **경로를 돌린다.** 삭제하면 테스트를 한 번 돌릴 때마다 운영 중 사이트의
| 라우트 캐시가 사라지고(재생성은 다음 확장 작업까지 일어나지 않는다), 그 사이 모든 요청이
| 라우트 파일 스캔 경로로 떨어진다. 돌려놓은 경로의 파일은 만들지 않으므로
| `routesAreCached()` 가 false 가 되어 라우트 파일이 정상 실행되고, 테스트 안에서
| `route:cache` 를 굽더라도 그 산출물이 운영 캐시를 덮지 않는다.
|
| config 캐시(위)와 달리 삭제가 아니라 리다이렉트인 이유가 이것이다 — config 캐시는
| `config:cache` 로 즉시 복구되지만 라우트 캐시는 복구 시점이 확장 작업에 묶여 있다.
|
*/
$testingRoutesCache = 'storage/framework/testing/routes-v7.php';
putenv('APP_ROUTES_CACHE='.$testingRoutesCache);
$_ENV['APP_ROUTES_CACHE'] = $testingRoutesCache;
$_SERVER['APP_ROUTES_CACHE'] = $testingRoutesCache;
/*
|--------------------------------------------------------------------------
| 패키지 매니페스트 캐시 격리 (테스트 환경 보장)
|--------------------------------------------------------------------------
|
| `bootstrap/cache/packages.php` · `services.php` 도 라우트 캐시와 같이 **경로를 돌린다.**
| 코어 업데이트 흐름은 이 두 파일을 지우고 다시 만드는 코드를 여럿 갖고 있고(spawn 직전 선정리,
| `bootstrap/app.php` 의 업데이트 트리 자가 치유, `clearAllCaches()` 의 `package:discover`), 그
| 코드를 태우는 테스트 가운데 몇은 `proc_open` 으로 **진짜 자식 프로세스**를 띄운다. 자식은 부모의
| 테스트 격리(임시 base path·목)를 하나도 물려받지 못하고 env 만 물려받으므로, 경로를 env 로
| 돌려 두는 것이 부모와 자식을 한 번에 격리하는 유일한 수단이다. 돌리지 않으면 테스트를 한 번
| 돌릴 때마다 개발 클론의 실제 매니페스트가 지워지고 다시 만들어진다 — 오늘은 재생성 내용이 같아
| 무해하지만, 그 사이 다른 프로세스가 부팅하면 매번 재빌드를 겪고, 테스트가 실제 파일을 건드리는
| 구조 자체가 `.env` 삭제 사고와 같은 부류다.
|
| 자기 경로를 따로 쓰는 테스트(`CoreUpdateCommandStalePackageManifestTest` 등)는 여기 값을
| 원값으로 보관·복원하므로 그대로 동작한다.
|
*/
$testingManifestDir = __DIR__.'/../storage/framework/testing';
if (! is_dir($testingManifestDir)) {
@mkdir($testingManifestDir, 0777, true);
}
foreach ([
'APP_PACKAGES_CACHE' => 'storage/framework/testing/packages.php',
'APP_SERVICES_CACHE' => 'storage/framework/testing/services.php',
] as $manifestEnvKey => $manifestRelativePath) {
putenv($manifestEnvKey.'='.$manifestRelativePath);
$_ENV[$manifestEnvKey] = $manifestRelativePath;
$_SERVER[$manifestEnvKey] = $manifestRelativePath;
}
unset($testingManifestDir, $manifestEnvKey, $manifestRelativePath);
// Composer 오토로더 로드
$loader = require __DIR__.'/../vendor/autoload.php';
// Extension 오토로드 중복 등록 방지
define('G7_EXTENSION_AUTOLOAD_REGISTERED', true);
/*
|--------------------------------------------------------------------------
| _bundled 확장 우선 오토로드 (테스트 환경 전용)
|--------------------------------------------------------------------------
|
| _bundled 디렉토리의 확장을 활성 디렉토리보다 먼저 등록(prepend)합니다.
| 이를 통해 _bundled에서 직접 테스트를 실행할 수 있습니다.
|
| 실행 순서:
| 1. _bundled classmap (module.php/plugin.php) require_once → 선점
| 2. _bundled PSR-4 addPsr4(prepend=true) → 우선 검색
| 3. autoload-extensions.php → 활성 디렉토리는 후순위
|
*/
// _bundled 확장 식별자 목록 (classmap 중복 로드 방지용)
$bundledExtensionIdentifiers = [];
// _bundled 모듈 오토로드 (prepend)
$bundledModulesDir = __DIR__.'/../modules/_bundled';
if (is_dir($bundledModulesDir)) {
$bundledModules = array_filter(scandir($bundledModulesDir), function ($dir) use ($bundledModulesDir) {
return $dir !== '.' && $dir !== '..' && is_dir($bundledModulesDir.'/'.$dir);
});
foreach ($bundledModules as $moduleDir) {
$modulePath = $bundledModulesDir.'/'.$moduleDir;
// _bundled 식별자 기록 (classmap 중복 방지용)
$bundledExtensionIdentifiers[] = $moduleDir;
// Classmap 선점 (module.php)
// class_exists 가드: artisan 초기 부팅에서 활성 디렉토리 버전이 로드되었을 수 있음
$modulePhp = $modulePath.'/module.php';
if (file_exists($modulePhp)) {
$parts = explode('-', $moduleDir);
$vendor = ucfirst($parts[0]);
$modName = isset($parts[1]) ? str_replace('_', '', ucwords($parts[1], '_')) : '';
$moduleClass = "Modules\\{$vendor}\\{$modName}\\Module";
if (! class_exists($moduleClass, false)) {
require_once $modulePhp;
}
}
// composer.json에서 오토로드 정보 읽기
$composerJson = $modulePath.'/composer.json';
if (file_exists($composerJson)) {
$composerData = json_decode(file_get_contents($composerJson), true);
// PSR-4 네임스페이스 prepend 등록
if (! empty($composerData['autoload']['psr-4'])) {
foreach ($composerData['autoload']['psr-4'] as $namespace => $paths) {
$paths = (array) $paths;
foreach ($paths as $path) {
$absolutePath = $modulePath.'/'.$path;
if (is_dir($absolutePath)) {
$loader->addPsr4($namespace, $absolutePath, true);
}
}
}
}
// files 오토로드 (helpers.php 등)
if (! empty($composerData['autoload']['files'])) {
foreach ($composerData['autoload']['files'] as $file) {
$absoluteFile = $modulePath.'/'.$file;
if (file_exists($absoluteFile)) {
require_once $absoluteFile;
}
}
}
}
}
}
// _bundled 플러그인 오토로드 (prepend)
$bundledPluginsDir = __DIR__.'/../plugins/_bundled';
if (is_dir($bundledPluginsDir)) {
$bundledPlugins = array_filter(scandir($bundledPluginsDir), function ($dir) use ($bundledPluginsDir) {
return $dir !== '.' && $dir !== '..' && is_dir($bundledPluginsDir.'/'.$dir);
});
foreach ($bundledPlugins as $pluginDir) {
$pluginPath = $bundledPluginsDir.'/'.$pluginDir;
// _bundled 식별자 기록 (classmap 중복 방지용)
$bundledExtensionIdentifiers[] = $pluginDir;
// Classmap 선점 (plugin.php)
// class_exists 가드: artisan 초기 부팅에서 활성 디렉토리 버전이 로드되었을 수 있음
$pluginPhp = $pluginPath.'/plugin.php';
if (file_exists($pluginPhp)) {
$parts = explode('-', $pluginDir);
$vendor = ucfirst($parts[0]);
$plgName = isset($parts[1]) ? str_replace('_', '', ucwords($parts[1], '_')) : '';
$pluginClass = "Plugins\\{$vendor}\\{$plgName}\\Plugin";
if (! class_exists($pluginClass, false)) {
require_once $pluginPhp;
}
}
// composer.json에서 오토로드 정보 읽기
$composerJson = $pluginPath.'/composer.json';
if (file_exists($composerJson)) {
$composerData = json_decode(file_get_contents($composerJson), true);
// PSR-4 네임스페이스 prepend 등록
if (! empty($composerData['autoload']['psr-4'])) {
foreach ($composerData['autoload']['psr-4'] as $namespace => $paths) {
$paths = (array) $paths;
foreach ($paths as $path) {
$absolutePath = $pluginPath.'/'.$path;
if (is_dir($absolutePath)) {
$loader->addPsr4($namespace, $absolutePath, true);
}
}
}
}
// files 오토로드 (helpers.php 등)
if (! empty($composerData['autoload']['files'])) {
foreach ($composerData['autoload']['files'] as $file) {
$absoluteFile = $pluginPath.'/'.$file;
if (file_exists($absoluteFile)) {
require_once $absoluteFile;
}
}
}
}
}
}
// 확장(모듈/플러그인) 오토로드 등록
$extensionAutoloadFile = __DIR__.'/../bootstrap/cache/autoload-extensions.php';
if (file_exists($extensionAutoloadFile)) {
$extensionAutoloads = require $extensionAutoloadFile;
// PSR-4 네임스페이스 등록
// _bundled에서 이미 prepend로 등록된 확장의 활성 디렉토리 PSR-4 경로는 스킵
// (Windows에서 대소문자 미구분으로 plugin.php = Plugin.php가 되어 중복 로드 발생 방지)
if (! empty($extensionAutoloads['psr4'])) {
foreach ($extensionAutoloads['psr4'] as $namespace => $paths) {
$paths = (array) $paths;
foreach ($paths as $path) {
// _bundled 식별자와 일치하는 경로는 스킵
$skip = false;
foreach ($bundledExtensionIdentifiers as $identifier) {
if (str_contains($path, '/'.$identifier.'/')) {
$skip = true;
break;
}
}
if ($skip) {
continue;
}
$absolutePath = __DIR__.'/../'.$path;
if (is_dir($absolutePath)) {
$loader->addPsr4($namespace, $absolutePath);
}
}
}
}
// Classmap 파일 로드 (module.php, plugin.php)
// _bundled에서 이미 로드된 확장의 classmap은 스킵 (클래스 중복 선언 방지)
if (! empty($extensionAutoloads['classmap'])) {
foreach ($extensionAutoloads['classmap'] as $file) {
// _bundled 식별자와 일치하는 경로인지 확인
// 예: "modules/sirsoft-board/module.php" → 식별자 "sirsoft-board"
$skip = false;
foreach ($bundledExtensionIdentifiers as $identifier) {
if (str_contains($file, '/'.$identifier.'/')) {
$skip = true;
break;
}
}
if ($skip) {
continue;
}
$absolutePath = __DIR__.'/../'.$file;
if (file_exists($absolutePath)) {
require_once $absolutePath;
}
}
}
}
/*
|--------------------------------------------------------------------------
| 확장 vendor(제3자 composer 패키지) 오토로드 등록
|--------------------------------------------------------------------------
|
| `autoload-extensions.php` 의 `vendor_autoloads` 를 소비하는 진입점은 `public/index.php`
| 와 `artisan` 뿐이라, PHPUnit 프로세스에서는 확장 전용 composer 패키지(`\HTMLPurifier` 등)가
| 오토로드되지 않았다. 그 패키지를 쓰는 코드 경로가 통째로 테스트 불가였고, 그 결손은 아무도
| 그 경로를 테스트하지 않는 동안 드러나지 않는다 (공개 #125).
|
| 확장 vendor 의 `autoload.php` 를 그대로 require 하지 않고 생성된 맵만 읽어 자체 로더를 만든다.
| 그 오토로더를 그대로 쓰면 두 가지가 깨진다.
|
| 1. 확장 자신의 PSR-4 와 files 를 **활성 디렉토리**로 매핑하고 자신을 prepend 로 걸어, 위에서
| _bundled 를 prepend 한 등록을 이긴다 — 테스트가 `_bundled` 가 아니라 활성 디렉토리 사본을
| 검증하게 되어 "_bundled 에서만 작업한다" 는 규율이 조용히 깨진다.
| 2. Composer 로더는 `vendorDir` 를 갖고 자신을 `ClassLoader::getRegisteredLoaders()` 맨 앞에
| 넣는데, `Illuminate\Foundation\Testing\TestCase::createApplication()` 이
| `Application::inferBasePath()` 로 그 첫 항목에서 base path 를 유추한다. 이후 테스트의 앱
| 부팅이 `modules/{id}/bootstrap/app.php` 를 찾다 실패한다. (운영 진입점은 basePath 를 명시
| 전달하므로 영향이 없다.)
|
| 그래서 `Modules\` / `Plugins\` 로 시작하는 항목은 전부 제외하고 — 확장 자기 코드는 위에서
| 이미 등록했다 — `vendorDir` 없는 단일 로더로 제3자 패키지만 등록한다. 루트 오토로더가 먼저
| 등록되어 있으므로 append 로 걸어 루트 vendor 가 우선하게 둔다(같은 패키지 중복 선언 방지).
|
*/
$extensionVendorLoader = new ClassLoader;
$extensionVendorClassMap = [];
$extensionVendorFiles = [];
$isExtensionOwnSymbol = static fn (string $symbol): bool => str_starts_with($symbol, 'Modules\\')
|| str_starts_with($symbol, 'Plugins\\');
foreach (['modules', 'plugins'] as $extensionType) {
foreach ([__DIR__.'/../'.$extensionType, __DIR__.'/../'.$extensionType.'/_bundled'] as $searchDir) {
if (! is_dir($searchDir)) {
continue;
}
foreach (scandir($searchDir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..' || $entry === '_bundled' || $entry === '_pending') {
continue;
}
$vendorDir = $searchDir.'/'.$entry.'/vendor';
$classmapFile = $vendorDir.'/composer/autoload_classmap.php';
if (! is_dir($vendorDir) || ! file_exists($classmapFile)) {
continue;
}
// 먼저 발견한 것(활성 디렉토리)이 이긴다 — 같은 클래스를 뒤에서 덮어쓰지 않는다.
foreach (require $classmapFile as $fqcn => $path) {
if (! $isExtensionOwnSymbol($fqcn) && ! isset($extensionVendorClassMap[$fqcn])) {
$extensionVendorClassMap[$fqcn] = $path;
}
}
$psr4File = $vendorDir.'/composer/autoload_psr4.php';
if (file_exists($psr4File)) {
foreach (require $psr4File as $prefix => $paths) {
if (! $isExtensionOwnSymbol($prefix)) {
$extensionVendorLoader->addPsr4($prefix, $paths);
}
}
}
// 패키지가 요구하는 부트스트랩 파일(HTMLPurifier 의 HTMLPURIFIER_PREFIX 정의 등).
// vendor 디렉토리 바깥의 항목(확장 자신의 helpers)은 제외한다.
$filesFile = $vendorDir.'/composer/autoload_files.php';
if (file_exists($filesFile)) {
$vendorReal = realpath($vendorDir) ?: $vendorDir;
foreach (require $filesFile as $file) {
if (str_starts_with(realpath($file) ?: $file, $vendorReal)) {
$extensionVendorFiles[] = $file;
}
}
}
}
}
}
if ($extensionVendorClassMap !== []) {
$extensionVendorLoader->addClassMap($extensionVendorClassMap);
}
// vendorDir 를 주지 않았으므로 register() 가 registeredLoaders 를 건드리지 않는다.
$extensionVendorLoader->register();
foreach ($extensionVendorFiles as $extensionVendorFile) {
require_once $extensionVendorFile;
}
// 모듈/플러그인 테스트 네임스페이스 등록 (tests/ 디렉토리)
$modulesDir = __DIR__.'/../modules';
if (is_dir($modulesDir)) {
$modules = array_filter(scandir($modulesDir), function ($dir) use ($modulesDir) {
return $dir !== '.' && $dir !== '..' && is_dir($modulesDir.'/'.$dir.'/tests');
});
foreach ($modules as $moduleDir) {
// sirsoft-ecommerce -> Modules\Sirsoft\Ecommerce\Tests
$parts = explode('-', $moduleDir);
$vendor = ucfirst($parts[0]);
$moduleName = isset($parts[1]) ? str_replace('_', '', ucwords($parts[1], '_')) : '';
$namespace = "Modules\\{$vendor}\\{$moduleName}\\Tests\\";
$testsPath = $modulesDir.'/'.$moduleDir.'/tests/';
if (is_dir($testsPath)) {
$loader->addPsr4($namespace, $testsPath);
}
}
}
// _bundled 모듈 테스트 네임스페이스 등록 (prepend)
$bundledModulesDir = __DIR__.'/../modules/_bundled';
if (is_dir($bundledModulesDir)) {
$bundledModules = array_filter(scandir($bundledModulesDir), function ($dir) use ($bundledModulesDir) {
return $dir !== '.' && $dir !== '..' && is_dir($bundledModulesDir.'/'.$dir.'/tests');
});
foreach ($bundledModules as $moduleDir) {
$parts = explode('-', $moduleDir);
$vendor = ucfirst($parts[0]);
$moduleName = isset($parts[1]) ? str_replace('_', '', ucwords($parts[1], '_')) : '';
$namespace = "Modules\\{$vendor}\\{$moduleName}\\Tests\\";
$testsPath = $bundledModulesDir.'/'.$moduleDir.'/tests/';
if (is_dir($testsPath)) {
$loader->addPsr4($namespace, $testsPath, true);
}
}
}
// 플러그인 소스 및 테스트 네임스페이스 등록
// (미설치 플러그인도 테스트할 수 있도록 autoload-extensions.php 보완)
$pluginsDir = __DIR__.'/../plugins';
if (is_dir($pluginsDir)) {
$plugins = array_filter(scandir($pluginsDir), function ($dir) use ($pluginsDir) {
return $dir !== '.' && $dir !== '..' && is_dir($pluginsDir.'/'.$dir);
});
foreach ($plugins as $pluginDir) {
// sirsoft-tosspayments -> Plugins\Sirsoft\Tosspayments
$parts = explode('-', $pluginDir);
$vendor = ucfirst($parts[0]);
$pluginName = isset($parts[1]) ? str_replace('_', '', ucwords($parts[1], '_')) : '';
// 소스 네임스페이스 등록 (autoload-extensions.php에 없을 수 있음)
$srcPath = $pluginsDir.'/'.$pluginDir.'/src/';
if (is_dir($srcPath)) {
$srcNamespace = "Plugins\\{$vendor}\\{$pluginName}\\";
$loader->addPsr4($srcNamespace, $srcPath);
}
// 테스트 네임스페이스 등록
$testsPath = $pluginsDir.'/'.$pluginDir.'/tests/';
if (is_dir($testsPath)) {
$testNamespace = "Plugins\\{$vendor}\\{$pluginName}\\Tests\\";
$loader->addPsr4($testNamespace, $testsPath);
}
}
}
// _bundled 플러그인 테스트 네임스페이스 등록 (prepend)
$bundledPluginsDir = __DIR__.'/../plugins/_bundled';
if (is_dir($bundledPluginsDir)) {
$bundledPlugins = array_filter(scandir($bundledPluginsDir), function ($dir) use ($bundledPluginsDir) {
return $dir !== '.' && $dir !== '..' && is_dir($bundledPluginsDir.'/'.$dir.'/tests');
});
foreach ($bundledPlugins as $pluginDir) {
$parts = explode('-', $pluginDir);
$vendor = ucfirst($parts[0]);
$pluginName = isset($parts[1]) ? str_replace('_', '', ucwords($parts[1], '_')) : '';
$testsPath = $bundledPluginsDir.'/'.$pluginDir.'/tests/';
if (is_dir($testsPath)) {
$testNamespace = "Plugins\\{$vendor}\\{$pluginName}\\Tests\\";
$loader->addPsr4($testNamespace, $testsPath, true);
}
}
}