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 그대로다.
1030 lines
42 KiB
PHP
1030 lines
42 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Services;
|
|
|
|
use App\Extension\ModuleManager;
|
|
use App\Extension\PluginManager;
|
|
use App\Services\ExtensionBundleService;
|
|
use Illuminate\Contracts\Cache\Lock;
|
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\File;
|
|
use Mockery;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* [case:backend-30] ExtensionBundleService 단위 테스트
|
|
*
|
|
* 활성 확장 IIFE/CSS 의 priority 정렬, `\n;\n` 구분자 병합, sourceMappingURL
|
|
* 처리, 확장별 fault tolerance, 캐시 파일 생성/정리를 검증한다.
|
|
*/
|
|
class ExtensionBundleServiceTest extends TestCase
|
|
{
|
|
private string $fixtureDir;
|
|
|
|
private ModuleManager $moduleManager;
|
|
|
|
private PluginManager $pluginManager;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->fixtureDir = storage_path('framework/testing/ext-bundle-fixtures');
|
|
File::ensureDirectoryExists($this->fixtureDir);
|
|
|
|
$this->moduleManager = Mockery::mock(ModuleManager::class);
|
|
$this->pluginManager = Mockery::mock(PluginManager::class);
|
|
|
|
// 번들 캐시 디렉토리 초기화 (테스트 격리)
|
|
$bundleDir = storage_path('app/ext-bundles');
|
|
if (is_dir($bundleDir)) {
|
|
foreach (glob($bundleDir.'/*.{js,css}', GLOB_BRACE) as $f) {
|
|
@unlink($f);
|
|
}
|
|
}
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
if (is_dir($this->fixtureDir)) {
|
|
foreach (glob($this->fixtureDir.'/*') as $f) {
|
|
@unlink($f);
|
|
}
|
|
@rmdir($this->fixtureDir);
|
|
}
|
|
|
|
$bundleDir = storage_path('app/ext-bundles');
|
|
if (is_dir($bundleDir)) {
|
|
foreach (glob($bundleDir.'/*.{js,css}', GLOB_BRACE) as $f) {
|
|
@unlink($f);
|
|
}
|
|
}
|
|
|
|
Mockery::close();
|
|
parent::tearDown();
|
|
}
|
|
|
|
/**
|
|
* 지정 내용으로 fixture 에셋 파일을 만들고 절대 경로를 반환한다.
|
|
*/
|
|
private function writeFixture(string $name, string $content): string
|
|
{
|
|
$path = $this->fixtureDir.'/'.$name;
|
|
File::put($path, $content);
|
|
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* hasAssets/getAssetLoadingConfig/getBuiltAssetAbsolutePaths/getIdentifier 를
|
|
* 노출하는 가짜 확장 인스턴스를 만든다.
|
|
*/
|
|
private function fakeExtension(string $identifier, int $priority, ?string $jsPath, ?string $cssPath, string $strategy = 'global', ?string $cssRelPath = null): object
|
|
{
|
|
$ext = Mockery::mock();
|
|
$ext->shouldReceive('hasAssets')->andReturn(true);
|
|
$ext->shouldReceive('getIdentifier')->andReturn($identifier);
|
|
$ext->shouldReceive('getAssetLoadingConfig')->andReturn([
|
|
'strategy' => $strategy,
|
|
'priority' => $priority,
|
|
'dependencies' => [],
|
|
]);
|
|
|
|
$paths = [];
|
|
if ($jsPath !== null) {
|
|
$paths['js'] = $jsPath;
|
|
}
|
|
if ($cssPath !== null) {
|
|
$paths['css'] = $cssPath;
|
|
}
|
|
$ext->shouldReceive('getBuiltAssetAbsolutePaths')->andReturn($paths);
|
|
|
|
// 확장 루트 기준 상대 경로 — CSS 상대 참조 해석의 기준점
|
|
$relative = [];
|
|
if ($cssRelPath !== null) {
|
|
$relative['css'] = $cssRelPath;
|
|
}
|
|
$ext->shouldReceive('getBuiltAssetPaths')->andReturn($relative);
|
|
|
|
return $ext;
|
|
}
|
|
|
|
/**
|
|
* 에셋 **매니페스트 선언**(`getAssets()`)을 노출하는 가짜 확장 인스턴스를 만든다.
|
|
*
|
|
* 산출물 경로(`getBuiltAssetAbsolutePaths`)는 기본적으로 존재하지 않는 파일을 가리킨다 —
|
|
* "선언은 있는데 산출물이 없다"(dist 소실) 상태를 그대로 재현하기 위해서다.
|
|
*
|
|
* 선언 축 게터(`getDeclaredAssetAbsolutePaths`)는 파일 존재와 무관하게 선언된 kind 마다
|
|
* 경로를 돌려준다. `$declaredPaths` 로 그 경로를 지정하면 실제로 만들어 둔 fixture 파일을
|
|
* 가리키게 해 "존재하되 비어 있음" 상태를 만들 수 있다.
|
|
*
|
|
* @param string $identifier 확장 식별자
|
|
* @param int $priority 로딩 우선순위
|
|
* @param array<string, mixed> $assets 매니페스트 assets 선언
|
|
* @param string $strategy 로딩 전략
|
|
* @param array<string, string>|null $declaredPaths kind => 절대 경로 (미지정 시 fixtureDir 의 부재 경로)
|
|
*/
|
|
private function fakeExtensionWithAssets(
|
|
string $identifier,
|
|
int $priority,
|
|
array $assets,
|
|
string $strategy = 'global',
|
|
?array $declaredPaths = null,
|
|
?array $builtPaths = null
|
|
): object {
|
|
$ext = Mockery::mock();
|
|
$ext->shouldReceive('hasAssets')->andReturn($assets !== []);
|
|
$ext->shouldReceive('getIdentifier')->andReturn($identifier);
|
|
$ext->shouldReceive('getAssetLoadingConfig')->andReturn([
|
|
'strategy' => $strategy,
|
|
'priority' => $priority,
|
|
'dependencies' => [],
|
|
]);
|
|
$ext->shouldReceive('getAssets')->andReturn($assets);
|
|
// 실제 확장은 존재하는 산출물만 built 경로로 돌려준다(getBuiltAssetPaths 가 file_exists 로 거른다).
|
|
// 기본값은 "산출물 없음" 상태를 흉내 내는 부재 경로다 — 존재하는 산출물을 흉내 내려면 $builtPaths 로 지정한다.
|
|
$ext->shouldReceive('getBuiltAssetAbsolutePaths')->andReturn(
|
|
$builtPaths ?? array_map(fn () => $this->fixtureDir.'/missing-'.$identifier.'.out', $assets)
|
|
);
|
|
$ext->shouldReceive('getBuiltAssetPaths')->andReturn(
|
|
array_map(fn () => 'dist/css/module.css', $assets)
|
|
);
|
|
|
|
$declared = $declaredPaths ?? array_combine(
|
|
array_keys($assets),
|
|
array_map(fn ($kind) => $this->fixtureDir.'/declared-'.$identifier.'.'.$kind, array_keys($assets))
|
|
);
|
|
$ext->shouldReceive('getDeclaredAssetAbsolutePaths')->andReturn($declared);
|
|
|
|
return $ext;
|
|
}
|
|
|
|
private function service(): ExtensionBundleService
|
|
{
|
|
return new ExtensionBundleService($this->moduleManager, $this->pluginManager);
|
|
}
|
|
|
|
/**
|
|
* 선언 수는 **매니페스트 기준**이다 — 산출물 파일 존재 여부와 무관하다 (E2).
|
|
*
|
|
* 이 값이 "실제로 병합된 확장 수" 로 계산되면 dist 가 통째로 비어 있을 때
|
|
* `기대 0 = 결과 0` 이 되어 장애가 정상(빈 200)으로 위장된다. 실측 A/B: dist 를
|
|
* 비우면 수정 전 `modules/bundle/js` 가 빈 200, 수정 후 503 이다.
|
|
*
|
|
* @effects asset_declaration_count_reads_manifest_not_built_files
|
|
*/
|
|
public function test_counts_asset_declaring_extensions_from_manifest_not_built_output(): void
|
|
{
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-js' => $this->fakeExtensionWithAssets('ext-js', 10, ['js' => ['output' => 'dist/js/ext-js.iife.js']]),
|
|
'ext-both' => $this->fakeExtensionWithAssets('ext-both', 20, [
|
|
'js' => ['output' => 'dist/js/ext-both.iife.js'],
|
|
'css' => ['output' => 'dist/css/ext-both.css'],
|
|
]),
|
|
'ext-none' => $this->fakeExtensionWithAssets('ext-none', 30, []),
|
|
// 병합 대상 모집단과 같은 필터 — global 전략이 아니면 세지 않는다
|
|
'ext-layout' => $this->fakeExtensionWithAssets('ext-layout', 40, [
|
|
'js' => ['output' => 'dist/js/ext-layout.iife.js'],
|
|
], 'layout'),
|
|
]);
|
|
|
|
$service = $this->service();
|
|
|
|
$this->assertSame(2, $service->countAssetDeclaringExtensions('module', 'js'));
|
|
$this->assertSame(1, $service->countAssetDeclaringExtensions('module', 'css'));
|
|
|
|
// 산출물은 하나도 존재하지 않는다 → 병합 결과는 빈 문자열.
|
|
// 이 값은 **선언 축**이다 — 컨트롤러의 503 판정은 소실 축(findMissingDeclaredAssets)이
|
|
// 한다. 선언 > 0 그 자체는 장애 조건이 아니다(존재하되 비어 있으면 정상 빈 200).
|
|
$this->assertSame('', $service->getBundleFilePath('module', 'js', 12345));
|
|
}
|
|
|
|
/**
|
|
* 실행 순서는 오직 manifest `loading.priority` 오름차순이다 — 확장 이름을 지목하는
|
|
* 분기를 두지 않는다.
|
|
*
|
|
* @effects ordered_by_priority_ascending_only_no_name_hardcode
|
|
*/
|
|
public function test_orders_global_assets_by_priority_ascending(): void
|
|
{
|
|
$a = $this->writeFixture('a.js', '(function(){})()');
|
|
$b = $this->writeFixture('b.js', '(function(){})()');
|
|
$c = $this->writeFixture('c.js', '(function(){})()');
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-c' => $this->fakeExtension('ext-c', 30, $c, null),
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
|
'ext-b' => $this->fakeExtension('ext-b', 20, $b, null),
|
|
]);
|
|
|
|
$ordered = $this->service()->getOrderedGlobalAssetPaths('module');
|
|
|
|
$this->assertSame(['ext-a', 'ext-b', 'ext-c'], array_keys($ordered));
|
|
}
|
|
|
|
/**
|
|
* global 전략이 아닌 확장은 번들 모집단이 아니다 (레이아웃/지연 로딩이 따로 처리한다).
|
|
*
|
|
* @effects non_global_strategy_excluded_from_bundle
|
|
*/
|
|
public function test_skips_non_global_strategy(): void
|
|
{
|
|
$g = $this->writeFixture('g.js', '(function(){})()');
|
|
$l = $this->writeFixture('l.js', '(function(){})()');
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-global' => $this->fakeExtension('ext-global', 100, $g, null, 'global'),
|
|
'ext-layout' => $this->fakeExtension('ext-layout', 100, $l, null, 'layout'),
|
|
]);
|
|
|
|
$ordered = $this->service()->getOrderedGlobalAssetPaths('module');
|
|
|
|
$this->assertArrayHasKey('ext-global', $ordered);
|
|
$this->assertArrayNotHasKey('ext-layout', $ordered);
|
|
}
|
|
|
|
/**
|
|
* IIFE 사이는 `\n;\n` 로 잇는다 — 구분자가 없으면 ASI 경계가 깨져 번들 전체가 죽는다.
|
|
*
|
|
* @effects js_iife_joined_with_semicolon_newline_separator
|
|
*/
|
|
public function test_js_bundle_joins_iife_with_semicolon_newline_separator(): void
|
|
{
|
|
// 세미콜론 없이 끝나는 IIFE 2개 (ecommerce 형태)
|
|
$a = $this->writeFixture('a.js', '(function(){window.A=1})()');
|
|
$b = $this->writeFixture('b.js', '(function(){window.B=2})()');
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
|
'ext-b' => $this->fakeExtension('ext-b', 20, $b, null),
|
|
]);
|
|
|
|
$js = $this->service()->buildJsBundle('module');
|
|
|
|
// 두 IIFE 가 모두 포함되고 `\n;\n` 구분자로 구분
|
|
$this->assertStringContainsString('window.A=1', $js);
|
|
$this->assertStringContainsString('window.B=2', $js);
|
|
$this->assertStringContainsString("\n;\n", $js);
|
|
// priority 순서 (a 가 b 보다 먼저)
|
|
$this->assertLessThan(strpos($js, 'window.B=2'), strpos($js, 'window.A=1'));
|
|
}
|
|
|
|
/**
|
|
* @effects prod_strips_source_mapping_url
|
|
*/
|
|
public function test_prod_strips_source_mapping_url(): void
|
|
{
|
|
$this->app['env'] = 'production';
|
|
app()->detectEnvironment(fn () => 'production');
|
|
|
|
$a = $this->writeFixture('a.js', "(function(){})()\n//# sourceMappingURL=a.js.map");
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
|
]);
|
|
|
|
$js = $this->service()->buildJsBundle('module');
|
|
|
|
$this->assertStringNotContainsString('sourceMappingURL', $js);
|
|
}
|
|
|
|
/**
|
|
* @effects dev_rewrites_source_mapping_url_to_asset_serving_path
|
|
*/
|
|
public function test_dev_rewrites_source_mapping_url_to_asset_serving_path(): void
|
|
{
|
|
// 자산 URL 표기(경로형 vs `?file=` 쿼리형)는 사이트 설정이 정한다. 이 테스트가 보려는
|
|
// 것은 "개발 환경에서 소스맵 URL 이 에셋 서빙 주소로 재작성되는가" 이므로, 개발 사이트가
|
|
// 어느 모드를 쓰든 결과가 달라지지 않도록 경로형으로 고정한다.
|
|
config(['g7_settings.core.general.asset_url_mode' => 'extension']);
|
|
|
|
// 기본 testing 환경 = 비프로덕션 → dev rewrite 경로
|
|
$a = $this->writeFixture('a.js', "(function(){})()\n//# sourceMappingURL=a.js.map");
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
|
]);
|
|
|
|
$js = $this->service()->buildJsBundle('module');
|
|
|
|
$this->assertStringContainsString('sourceMappingURL=/api/modules/assets/ext-a/dist/js/a.js.map', $js);
|
|
}
|
|
|
|
public function test_prod_strips_css_source_mapping_url(): void
|
|
{
|
|
$this->app['env'] = 'production';
|
|
app()->detectEnvironment(fn () => 'production');
|
|
|
|
// CSS 는 JS 와 주석 문법이 달라 블록 주석으로 맵을 참조한다.
|
|
$css = $this->writeFixture('a.css', ".a{color:red}\n/*# sourceMappingURL=a.css.map */");
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, null, $css),
|
|
]);
|
|
|
|
$bundle = $this->service()->buildCssBundle('module');
|
|
|
|
$this->assertStringNotContainsString('sourceMappingURL', $bundle);
|
|
// 스타일 본문은 보존되어야 한다 (주석만 제거)
|
|
$this->assertStringContainsString('.a{color:red}', $bundle);
|
|
}
|
|
|
|
public function test_dev_keeps_css_source_mapping_url(): void
|
|
{
|
|
// 기본 testing 환경 = 비프로덕션 → 개발 디버깅을 위해 원본 유지
|
|
$css = $this->writeFixture('a.css', ".a{color:red}\n/*# sourceMappingURL=a.css.map */");
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, null, $css),
|
|
]);
|
|
|
|
$bundle = $this->service()->buildCssBundle('module');
|
|
|
|
$this->assertStringContainsString('sourceMappingURL', $bundle);
|
|
}
|
|
|
|
/**
|
|
* 한 확장의 읽기 실패가 번들 전체를 소실시키지 않는다 — 그 확장만 빠지고 나머지는 병합된다.
|
|
*
|
|
* @effects per_extension_fault_tolerance_skips_missing_file_keeps_rest
|
|
*/
|
|
public function test_per_extension_fault_tolerance_skips_missing_file(): void
|
|
{
|
|
$good = $this->writeFixture('good.js', '(function(){window.GOOD=1})()');
|
|
$missing = $this->fixtureDir.'/does-not-exist.js';
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-missing' => $this->fakeExtension('ext-missing', 10, $missing, null),
|
|
'ext-good' => $this->fakeExtension('ext-good', 20, $good, null),
|
|
]);
|
|
|
|
$js = $this->service()->buildJsBundle('module');
|
|
|
|
// 없는 파일은 skip, 정상 확장은 여전히 포함 (번들 전체 붕괴 안 함)
|
|
$this->assertStringContainsString('window.GOOD=1', $js);
|
|
}
|
|
|
|
/**
|
|
* 상대 참조를 가진 CSS 는 **제외되지 않고 치환되어** 병합된다.
|
|
*
|
|
* 종전 계약은 그 확장을 번들에서 통째로 제외하는 것이었고 주석은 "개별 폴백 유지" 라고
|
|
* 적었지만, 번들 URL 이 내려오면 프론트는 개별 로딩을 아예 타지 않는다
|
|
* (TemplateApp.loadExtensionAssets). 즉 제외 = 그 확장 스타일이 하나도 적용되지 않음
|
|
* 이었고, 오류도 로그 흔적도 화면 경고도 남지 않았다.
|
|
*
|
|
* @effects css_with_relative_url_rewritten_not_excluded
|
|
*/
|
|
public function test_css_with_relative_url_is_rewritten_not_excluded(): void
|
|
{
|
|
$safe = $this->writeFixture('safe.css', '.a{color:red}');
|
|
$relative = $this->writeFixture('rel.css', '.b{background:url(./img/x.png)}');
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-safe' => $this->fakeExtension('ext-safe', 10, null, $safe, cssRelPath: 'dist/css/module.css'),
|
|
'ext-rel' => $this->fakeExtension('ext-rel', 20, null, $relative, cssRelPath: 'dist/css/module.css'),
|
|
]);
|
|
|
|
$css = $this->service()->buildCssBundle('module');
|
|
|
|
// 두 확장 모두 병합에 남는다 — 제외가 사라졌다는 것이 이 축의 핵심이다.
|
|
$this->assertStringContainsString('.a{color:red}', $css);
|
|
$this->assertStringContainsString('.b{background:', $css);
|
|
|
|
// 상대 참조는 그대로 나가지 않는다 (그 주소는 병합본 기준으로 풀려 404 가 된다).
|
|
$this->assertStringNotContainsString('url(./img/x.png)', $css);
|
|
|
|
// 치환 결과는 그 확장의 절대 자산 URL 이며, CSS 가 놓인 디렉토리 기준으로 풀린다.
|
|
$this->assertStringContainsString('/api/modules/assets/ext-rel', $css);
|
|
$this->assertStringContainsString('dist/css/img/x.png', rawurldecode($css));
|
|
}
|
|
|
|
/**
|
|
* 절대·루트상대·data URI 참조는 병합에서도 손대지 않는다.
|
|
*/
|
|
public function test_css_bundle_leaves_absolute_references_untouched(): void
|
|
{
|
|
$path = $this->writeFixture('abs.css',
|
|
'.a{background:url(https://cdn.example.com/x.png)}'
|
|
.'.b{background:url(/build/ext/1/y.png)}'
|
|
.'.c{background:url(data:image/gif;base64,AA==)}'
|
|
);
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-abs' => $this->fakeExtension('ext-abs', 10, null, $path, cssRelPath: 'dist/css/module.css'),
|
|
]);
|
|
|
|
$css = $this->service()->buildCssBundle('module');
|
|
|
|
$this->assertStringContainsString('url(https://cdn.example.com/x.png)', $css);
|
|
$this->assertStringContainsString('url(/build/ext/1/y.png)', $css);
|
|
$this->assertStringContainsString('url(data:image/gif;base64,AA==)', $css);
|
|
}
|
|
|
|
/**
|
|
* @effects empty_bundle_returns_empty_path_and_empty_ok_response
|
|
*/
|
|
public function test_empty_bundle_returns_empty_path(): void
|
|
{
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
|
|
|
|
$path = $this->service()->getBundleFilePath('module', 'js', 12345);
|
|
|
|
$this->assertSame('', $path);
|
|
}
|
|
|
|
/**
|
|
* @effects prod_writes_versioned_cache_file_and_reuses_on_same_version
|
|
*/
|
|
public function test_prod_writes_versioned_cache_file_and_reuses_it(): void
|
|
{
|
|
$this->app['env'] = 'production';
|
|
app()->detectEnvironment(fn () => 'production');
|
|
|
|
$a = $this->writeFixture('a.js', '(function(){window.A=1})()');
|
|
// 캐시 적중 경로는 활성 확장을 다시 열거하지 않아야 하므로 정확히 1회로 조인다
|
|
$this->moduleManager->shouldReceive('getActiveModules')->once()->andReturn([
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
|
]);
|
|
|
|
$svc = $this->service();
|
|
$path1 = $svc->getBundleFilePath('module', 'js', 999);
|
|
|
|
$this->assertNotSame('', $path1);
|
|
$this->assertFileExists($path1);
|
|
$this->assertStringContainsString('module.999.js', $path1);
|
|
|
|
$content1 = (string) file_get_contents($path1);
|
|
|
|
// 같은 version 재요청 → 동일 파일 (캐시 히트)
|
|
$path2 = $svc->getBundleFilePath('module', 'js', 999);
|
|
$this->assertSame($path1, $path2);
|
|
$this->assertSame($content1, (string) file_get_contents($path2));
|
|
}
|
|
|
|
/**
|
|
* 비프로덕션은 같은 version 이어도 매 요청 다시 병합한다 (캐시 재사용 없음).
|
|
*
|
|
* 프로덕션은 같은 version 캐시가 있으면 그대로 재사용하지만, 비프로덕션에서 그러면
|
|
* 개발 중 rebuild 가 반영되지 않는다. 소스를 바꿨는데 화면이 그대로인 상태는 오류도
|
|
* 로그도 남지 않아 원인을 짚을 수 없다.
|
|
*
|
|
* @effects non_production_rebuilds_every_request_no_disk_cache
|
|
*/
|
|
public function test_non_production_rebuilds_every_request_without_cache_reuse(): void
|
|
{
|
|
$this->assertFalse(app()->environment('production'));
|
|
|
|
$a = $this->writeFixture('nonprod.js', '(function(){window.A=1})()');
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
|
]);
|
|
|
|
$svc = $this->service();
|
|
|
|
$path1 = $svc->getBundleFilePath('module', 'js', 4242);
|
|
$this->assertStringContainsString('window.A=1', (string) file_get_contents($path1));
|
|
|
|
// 소스를 바꾸고 **같은 version** 으로 다시 요청 → 재병합되어야 한다
|
|
File::put($a, '(function(){window.A=2})()');
|
|
$path2 = $svc->getBundleFilePath('module', 'js', 4242);
|
|
|
|
$this->assertSame($path1, $path2);
|
|
$this->assertStringContainsString('window.A=2', (string) file_get_contents($path2));
|
|
|
|
@unlink($path1);
|
|
}
|
|
|
|
/**
|
|
* @effects cleanup_removes_stale_version_bundles_only
|
|
*/
|
|
public function test_cleanup_removes_stale_version_bundles_only(): void
|
|
{
|
|
$bundleDir = storage_path('app/ext-bundles');
|
|
File::ensureDirectoryExists($bundleDir);
|
|
File::put($bundleDir.'/module.100.js', 'old');
|
|
File::put($bundleDir.'/module.200.js', 'current');
|
|
File::put($bundleDir.'/plugin.100.css', 'old');
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
|
|
|
|
$deleted = $this->service()->cleanupStaleBundles(200);
|
|
|
|
// version 200 외 파일만 삭제 (module.100.js, plugin.100.css = 2건)
|
|
$this->assertSame(2, $deleted);
|
|
$this->assertFileExists($bundleDir.'/module.200.js');
|
|
$this->assertFileDoesNotExist($bundleDir.'/module.100.js');
|
|
$this->assertFileDoesNotExist($bundleDir.'/plugin.100.css');
|
|
}
|
|
|
|
/**
|
|
* @effects clear_bundles_by_type_scopes_to_prefix
|
|
*/
|
|
public function test_clear_bundles_by_type(): void
|
|
{
|
|
$bundleDir = storage_path('app/ext-bundles');
|
|
File::ensureDirectoryExists($bundleDir);
|
|
File::put($bundleDir.'/module.100.js', 'm');
|
|
File::put($bundleDir.'/plugin.100.js', 'p');
|
|
|
|
$deleted = $this->service()->clearBundles('module');
|
|
|
|
$this->assertSame(1, $deleted);
|
|
$this->assertFileDoesNotExist($bundleDir.'/module.100.js');
|
|
$this->assertFileExists($bundleDir.'/plugin.100.js');
|
|
}
|
|
|
|
/**
|
|
* clearBundles 는 **현재 버전을 보존**한다 (E3).
|
|
*
|
|
* 현재 버전까지 지우면 같은 순간 서빙 중인 웹 요청이 "존재함" 판정 직후
|
|
* `filemtime()` 에서 500 을 낸다(bump 직후 TOCTOU). `cleanupStaleBundles` 와
|
|
* 정책이 갈라져 있던 것이 원인이었다.
|
|
*
|
|
* @effects clear_bundles_preserves_current_version
|
|
*/
|
|
public function test_clear_bundles_preserves_current_version(): void
|
|
{
|
|
$bundleDir = storage_path('app/ext-bundles');
|
|
File::ensureDirectoryExists($bundleDir);
|
|
|
|
$current = $this->service()->getCurrentVersion();
|
|
File::put($bundleDir."/module.{$current}.js", 'current');
|
|
File::put($bundleDir.'/module.100.js', 'old');
|
|
|
|
$deleted = $this->service()->clearBundles('module');
|
|
|
|
$this->assertSame(1, $deleted);
|
|
$this->assertFileExists(
|
|
$bundleDir."/module.{$current}.js",
|
|
'현재 버전이 삭제됐다 — 서빙 중인 요청이 filemtime 에서 500 을 낸다'
|
|
);
|
|
$this->assertFileDoesNotExist($bundleDir.'/module.100.js');
|
|
}
|
|
|
|
/**
|
|
* GC 는 원자적 쓰기의 임시 파일(`*.tmp.{pid}`)도 정리한다 — 단 나이 가드를 지킨다 (E4).
|
|
*
|
|
* 종전에는 이 파일명이 번들 파일 패턴에 맞지 않아 GC 대상에서 통째로 빠졌고,
|
|
* rename 이 실패한 만큼 영구 잔존했다(실측 560개).
|
|
*
|
|
* @effects cleanup_removes_stale_atomic_write_temp_files
|
|
*/
|
|
public function test_cleanup_removes_stale_temp_bundle_files_only(): void
|
|
{
|
|
$bundleDir = storage_path('app/ext-bundles');
|
|
File::ensureDirectoryExists($bundleDir);
|
|
|
|
$fresh = $bundleDir.'/module.200.js.tmp.11111';
|
|
$stale = $bundleDir.'/module.200.js.tmp.22222';
|
|
File::put($fresh, 'writing now');
|
|
File::put($stale, 'orphan');
|
|
touch($stale, time() - 3600);
|
|
clearstatcache();
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
|
|
|
|
try {
|
|
$deleted = $this->service()->cleanupStaleBundles(200);
|
|
|
|
$this->assertFileExists($fresh, '진행 중인 쓰기의 임시 파일이 삭제됐다');
|
|
$this->assertFileDoesNotExist($stale, '고아 임시 파일이 정리되지 않았다 — 영구 누적된다');
|
|
$this->assertSame(1, $deleted);
|
|
} finally {
|
|
// 번들 디렉토리는 테스트 간 공유된다 — 남기면 형제 케이스의 삭제 건수가 틀어진다.
|
|
@unlink($fresh);
|
|
@unlink($stale);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @effects plugin_bundle_gdpr_first_when_priority_lowest
|
|
*/
|
|
public function test_plugin_bundle_orders_by_priority_gdpr_first_when_lowest(): void
|
|
{
|
|
// gdpr 가 priority 50 으로 최상단(제약 1 회귀 가드) — 이름 하드코딩 아닌 선언 결과
|
|
$gdpr = $this->writeFixture('gdpr.js', '(function(){window.GDPR=1})()');
|
|
$other = $this->writeFixture('other.js', '(function(){window.OTHER=1})()');
|
|
|
|
$this->pluginManager->shouldReceive('getActivePlugins')->andReturn([
|
|
'sirsoft-other' => $this->fakeExtension('sirsoft-other', 100, $other, null),
|
|
'sirsoft-gdpr' => $this->fakeExtension('sirsoft-gdpr', 50, $gdpr, null),
|
|
]);
|
|
|
|
$ordered = $this->service()->getOrderedGlobalAssetPaths('plugin');
|
|
|
|
$this->assertSame('sirsoft-gdpr', array_key_first($ordered));
|
|
|
|
$js = $this->service()->buildJsBundle('plugin');
|
|
$this->assertLessThan(strpos($js, 'window.OTHER=1'), strpos($js, 'window.GDPR=1'));
|
|
}
|
|
|
|
/**
|
|
* 선언한 산출물이 **존재하되 0바이트**면 소실 목록에 넣지 않는다.
|
|
*
|
|
* 스타일 소스가 자리표시 주석뿐인 확장은 0바이트 CSS 를 내보내는 정당한 상태다.
|
|
* 이것을 소실로 세면 그 확장만 설치된 기본 구성이 통째로 503 이 된다.
|
|
*
|
|
* @effects empty_result_with_present_empty_artifacts_returns_200
|
|
*/
|
|
public function test_find_missing_declared_assets_ignores_present_zero_byte_files(): void
|
|
{
|
|
$cssPath = $this->writeFixture('present-empty.css', '');
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-empty-css' => $this->fakeExtensionWithAssets(
|
|
'ext-empty-css',
|
|
100,
|
|
['css' => ['output' => 'dist/css/module.css']],
|
|
'global',
|
|
['css' => $cssPath]
|
|
),
|
|
]);
|
|
|
|
$service = $this->service();
|
|
|
|
$this->assertSame(0, filesize($cssPath));
|
|
$this->assertSame([], $service->findMissingDeclaredAssets('module', 'css'));
|
|
// 선언 축은 그대로 1 — 두 축이 다른 것을 잰다
|
|
$this->assertSame(1, $service->countAssetDeclaringExtensions('module', 'css'));
|
|
// 병합 결과는 빈 문자열이지만 장애가 아니다
|
|
$this->assertSame('', $service->buildCssBundle('module'));
|
|
}
|
|
|
|
/**
|
|
* 선언한 산출물이 **없으면** 그 절대 경로를 소실 목록으로 돌려준다.
|
|
*
|
|
* non-global 전략 확장은 병합 모집단이 아니므로 소실 판정에서도 제외된다 —
|
|
* 두 판정이 같은 모집단을 써야 한쪽만 장애로 보는 어긋남이 생기지 않는다.
|
|
*
|
|
* @effects empty_result_with_missing_declared_artifact_returns_503
|
|
*/
|
|
public function test_find_missing_declared_assets_lists_absent_files(): void
|
|
{
|
|
$absent = $this->fixtureDir.'/absent-module.css';
|
|
$absentLayout = $this->fixtureDir.'/absent-layout.css';
|
|
@unlink($absent);
|
|
@unlink($absentLayout);
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-gone' => $this->fakeExtensionWithAssets(
|
|
'ext-gone',
|
|
100,
|
|
['css' => ['output' => 'dist/css/module.css']],
|
|
'global',
|
|
['css' => $absent]
|
|
),
|
|
'ext-layout' => $this->fakeExtensionWithAssets(
|
|
'ext-layout',
|
|
100,
|
|
['css' => ['output' => 'dist/css/module.css']],
|
|
'layout',
|
|
['css' => $absentLayout]
|
|
),
|
|
]);
|
|
|
|
$missing = $this->service()->findMissingDeclaredAssets('module', 'css');
|
|
|
|
$this->assertSame([$absent], $missing);
|
|
$this->assertNotContains($absentLayout, $missing);
|
|
}
|
|
|
|
/**
|
|
* 플러그인 축도 같은 판정을 쓴다 — 모집단만 다르다.
|
|
*
|
|
* @effects bundle_decision_is_shared_across_extension_types
|
|
*/
|
|
public function test_find_missing_declared_assets_covers_plugins(): void
|
|
{
|
|
$absent = $this->fixtureDir.'/absent-plugin.js';
|
|
@unlink($absent);
|
|
$present = $this->writeFixture('present-plugin.js', '');
|
|
|
|
$this->pluginManager->shouldReceive('getActivePlugins')->andReturn([
|
|
'plg-gone' => $this->fakeExtensionWithAssets(
|
|
'plg-gone',
|
|
100,
|
|
['js' => ['output' => 'dist/js/plugin.iife.js']],
|
|
'global',
|
|
['js' => $absent]
|
|
),
|
|
'plg-empty' => $this->fakeExtensionWithAssets(
|
|
'plg-empty',
|
|
100,
|
|
['js' => ['output' => 'dist/js/plugin.iife.js']],
|
|
'global',
|
|
['js' => $present]
|
|
),
|
|
]);
|
|
|
|
$this->assertSame([$absent], $this->service()->findMissingDeclaredAssets('plugin', 'js'));
|
|
}
|
|
|
|
/**
|
|
* 프로덕션에서 같은 version 캐시가 있으면 **빌드도 원본 읽기도 하지 않는다**.
|
|
*
|
|
* 캐시 적중 판정이 빌드 뒤에 있으면 캐시가 있어도 요청마다 활성 확장을 열거하고
|
|
* 산출물 파일을 전부 읽는다. 응답은 정상 200 이라 타이밍 말고는 드러나는 증상이
|
|
* 없고, 원본이 사라진 순간에는 캐시가 멀쩡한데도 빈 경로가 반환되어 503 이 된다.
|
|
*
|
|
* @effects prod_cache_hit_skips_build_and_source_reads
|
|
*/
|
|
public function test_prod_cache_hit_returns_cached_path_without_enumerating_or_reading_sources(): void
|
|
{
|
|
$this->app['env'] = 'production';
|
|
app()->detectEnvironment(fn () => 'production');
|
|
|
|
$a = $this->writeFixture('cache-hit.js', '(function(){window.A=1})()');
|
|
|
|
$enumerations = 0;
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturnUsing(function () use (&$enumerations, $a) {
|
|
$enumerations++;
|
|
|
|
return ['ext-a' => $this->fakeExtension('ext-a', 10, $a, null)];
|
|
});
|
|
|
|
$svc = $this->service();
|
|
|
|
$path1 = $svc->getBundleFilePath('module', 'js', 424242);
|
|
$this->assertNotSame('', $path1);
|
|
$this->assertFileExists($path1);
|
|
$this->assertSame(1, $enumerations);
|
|
|
|
$before = (string) file_get_contents($path1);
|
|
|
|
// 원본 소실 — 캐시가 있으므로 응답에 영향이 없어야 한다
|
|
@unlink($a);
|
|
|
|
$path2 = $svc->getBundleFilePath('module', 'js', 424242);
|
|
|
|
$this->assertSame($path1, $path2);
|
|
$this->assertSame(1, $enumerations, '캐시 적중 시 활성 확장을 다시 열거하면 안 된다');
|
|
$this->assertSame($before, (string) file_get_contents($path2));
|
|
}
|
|
|
|
/**
|
|
* 프로덕션은 같은 version 이면 원본이 바뀌어도 캐시를 그대로 서빙한다.
|
|
*
|
|
* 비프로덕션 거울 테스트(test_non_production_rebuilds_every_request_without_cache_reuse)와
|
|
* 짝을 이룬다 — 원본 교체는 version bump 로만 반영된다.
|
|
*
|
|
* @effects prod_cache_hit_skips_build_and_source_reads
|
|
*/
|
|
public function test_prod_cache_hit_does_not_rebuild_when_source_changes_on_same_version(): void
|
|
{
|
|
$this->app['env'] = 'production';
|
|
app()->detectEnvironment(fn () => 'production');
|
|
|
|
$a = $this->writeFixture('prod-stable.js', '(function(){window.A=1})()');
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
|
]);
|
|
|
|
$svc = $this->service();
|
|
|
|
$path1 = $svc->getBundleFilePath('module', 'js', 424243);
|
|
$this->assertStringContainsString('window.A=1', (string) file_get_contents($path1));
|
|
|
|
File::put($a, '(function(){window.A=2})()');
|
|
$path2 = $svc->getBundleFilePath('module', 'js', 424243);
|
|
|
|
$this->assertSame($path1, $path2);
|
|
$this->assertStringContainsString('window.A=1', (string) file_get_contents($path2));
|
|
$this->assertStringNotContainsString('window.A=2', (string) file_get_contents($path2));
|
|
}
|
|
|
|
/**
|
|
* 캐시 미스는 같은 (type, kind, version) 잠금으로 1회 빌드에 수렴하고,
|
|
* 잠금 뒤에 캐시를 **다시 확인**한다.
|
|
*
|
|
* 다른 프로세스가 대기 중에 캐시를 완성했다면 이쪽은 빌드하지 않아야 한다.
|
|
*
|
|
* @effects prod_cache_miss_builds_once_under_lock_and_rechecks
|
|
*/
|
|
public function test_prod_cache_miss_serializes_concurrent_builds_and_rechecks_cache_inside_lock(): void
|
|
{
|
|
$this->app['env'] = 'production';
|
|
app()->detectEnvironment(fn () => 'production');
|
|
|
|
$bundleDir = storage_path('app/ext-bundles');
|
|
File::ensureDirectoryExists($bundleDir);
|
|
$cachePath = $bundleDir.'/module.424244.js';
|
|
@unlink($cachePath);
|
|
|
|
$enumerations = 0;
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturnUsing(function () use (&$enumerations) {
|
|
$enumerations++;
|
|
|
|
return [];
|
|
});
|
|
|
|
$lock = Mockery::mock(Lock::class);
|
|
// 대기 중 다른 프로세스가 캐시를 완성한 상황
|
|
$lock->shouldReceive('block')->once()->andReturnUsing(function () use ($cachePath) {
|
|
File::put($cachePath, '(function(){window.OTHER=1})()');
|
|
|
|
return true;
|
|
});
|
|
$lock->shouldReceive('release')->once()->andReturn(true);
|
|
|
|
Cache::shouldReceive('lock')->once()->andReturn($lock);
|
|
|
|
$path = $this->service()->getBundleFilePath('module', 'js', 424244);
|
|
|
|
// 경로 구분자는 스토리지 드라이버가 정한다 — 같은 파일을 가리키는지만 본다
|
|
$this->assertSame(realpath($cachePath), realpath($path));
|
|
$this->assertSame(0, $enumerations, '잠금 뒤 재확인이 적중하면 빌드하지 않아야 한다');
|
|
|
|
@unlink($cachePath);
|
|
}
|
|
|
|
/**
|
|
* 잠금 대기가 초과되어도 실패로 바꾸지 않는다 — 잠금 없이 각자 빌드한다.
|
|
*
|
|
* @effects prod_build_lock_failure_falls_back_to_unlocked_build
|
|
*/
|
|
public function test_prod_cache_miss_builds_without_lock_when_lock_times_out(): void
|
|
{
|
|
$this->app['env'] = 'production';
|
|
app()->detectEnvironment(fn () => 'production');
|
|
|
|
$a = $this->writeFixture('lock-timeout.js', '(function(){window.A=1})()');
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
|
]);
|
|
|
|
$lock = Mockery::mock(Lock::class);
|
|
$lock->shouldReceive('block')->once()->andThrow(new LockTimeoutException);
|
|
$lock->shouldReceive('release')->never();
|
|
|
|
Cache::shouldReceive('lock')->once()->andReturn($lock);
|
|
|
|
$path = $this->service()->getBundleFilePath('module', 'js', 424245);
|
|
|
|
$this->assertNotSame('', $path);
|
|
$this->assertFileExists($path);
|
|
$this->assertStringContainsString('window.A=1', (string) file_get_contents($path));
|
|
}
|
|
|
|
/**
|
|
* 캐시 저장소가 잠금을 제공하지 못해도(드라이버 미지원·권한 등) 빌드는 계속한다.
|
|
*
|
|
* @effects prod_build_lock_failure_falls_back_to_unlocked_build
|
|
*/
|
|
public function test_prod_cache_miss_builds_without_lock_when_store_cannot_lock(): void
|
|
{
|
|
$this->app['env'] = 'production';
|
|
app()->detectEnvironment(fn () => 'production');
|
|
|
|
$a = $this->writeFixture('lock-unavailable.js', '(function(){window.A=1})()');
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
|
]);
|
|
|
|
Cache::shouldReceive('lock')->once()->andThrow(new \RuntimeException('lock unsupported'));
|
|
|
|
$path = $this->service()->getBundleFilePath('module', 'js', 424246);
|
|
|
|
$this->assertNotSame('', $path);
|
|
$this->assertFileExists($path);
|
|
$this->assertStringContainsString('window.A=1', (string) file_get_contents($path));
|
|
}
|
|
|
|
/**
|
|
* 선언 산출물이 **전부 존재하되 비어 있으면** 0바이트 캐시 파일을 만든다.
|
|
*
|
|
* 그래야 정적 게시 대상이 되어 브라우저가 웹서버에서 직접 받는다. 만들지 않으면
|
|
* 그 구성의 모든 페이지 로드가 PHP 를 경유하고, 요청마다 컨트롤러가 열거를 세 번
|
|
* 반복한다(경로 조회 → 재빌드 → 소실 판정).
|
|
*
|
|
* @effects prod_empty_result_with_present_artifacts_is_cached_as_zero_byte_file
|
|
*/
|
|
public function test_prod_caches_zero_byte_bundle_when_declared_artifacts_exist_but_empty(): void
|
|
{
|
|
$this->app['env'] = 'production';
|
|
app()->detectEnvironment(fn () => 'production');
|
|
|
|
$cssPath = $this->writeFixture('zero-byte.css', '');
|
|
|
|
$enumerations = 0;
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturnUsing(function () use (&$enumerations, $cssPath) {
|
|
$enumerations++;
|
|
|
|
return [
|
|
'ext-empty-css' => $this->fakeExtensionWithAssets(
|
|
'ext-empty-css',
|
|
100,
|
|
['css' => ['output' => 'dist/css/module.css']],
|
|
'global',
|
|
['css' => $cssPath],
|
|
['css' => $cssPath]
|
|
),
|
|
];
|
|
});
|
|
|
|
$svc = $this->service();
|
|
|
|
$path = $svc->getBundleFilePath('module', 'css', 424247);
|
|
|
|
$this->assertNotSame('', $path);
|
|
$this->assertFileExists($path);
|
|
$this->assertSame(0, filesize($path));
|
|
|
|
$enumerationsAfterFirst = $enumerations;
|
|
$this->assertSame($path, $svc->getBundleFilePath('module', 'css', 424247));
|
|
$this->assertSame($enumerationsAfterFirst, $enumerations, '0바이트 캐시도 적중하면 열거하지 않는다');
|
|
}
|
|
|
|
/**
|
|
* 선언 산출물이 **소실**이면 캐시하지 않는다 — 503 계약을 보존한다.
|
|
*
|
|
* 0바이트 캐시가 소실 상태까지 가리면 배포 중 dist 가 빈 장애가 정상(빈 200)으로
|
|
* 위장된다.
|
|
*
|
|
* @effects prod_empty_result_with_missing_artifact_is_not_cached
|
|
*/
|
|
public function test_prod_does_not_cache_when_declared_artifact_missing(): void
|
|
{
|
|
$this->app['env'] = 'production';
|
|
app()->detectEnvironment(fn () => 'production');
|
|
|
|
$absent = $this->fixtureDir.'/absent-prod.css';
|
|
@unlink($absent);
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-gone' => $this->fakeExtensionWithAssets(
|
|
'ext-gone',
|
|
100,
|
|
['css' => ['output' => 'dist/css/module.css']],
|
|
'global',
|
|
['css' => $absent]
|
|
),
|
|
]);
|
|
|
|
$path = $this->service()->getBundleFilePath('module', 'css', 424248);
|
|
|
|
$this->assertSame('', $path);
|
|
$this->assertFileDoesNotExist(storage_path('app/ext-bundles/module.424248.css'));
|
|
}
|
|
|
|
/**
|
|
* 선언한 확장이 **하나도 없어도** 0바이트 캐시를 만든다(정적 게시 가능).
|
|
*
|
|
* @effects prod_empty_result_with_present_artifacts_is_cached_as_zero_byte_file
|
|
*/
|
|
public function test_prod_caches_zero_byte_bundle_when_nothing_declared(): void
|
|
{
|
|
$this->app['env'] = 'production';
|
|
app()->detectEnvironment(fn () => 'production');
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
|
|
|
|
$path = $this->service()->getBundleFilePath('module', 'css', 424249);
|
|
|
|
$this->assertNotSame('', $path);
|
|
$this->assertFileExists($path);
|
|
$this->assertSame(0, filesize($path));
|
|
}
|
|
|
|
/**
|
|
* 병합 단계에서 건너뛴 확장이 하나라도 있으면 캐시하지 않는다 — 파일은 존재·판독 가능한데
|
|
* 읽기·치환이 실패한 상태가 0바이트(또는 일부 빠진) 캐시로 굳으면 버전 bump 전까지 그
|
|
* 확장 스타일이 사라진 채 고정된다. 종전처럼 매 요청 재시도해 원인이 사라지면 회복한다.
|
|
*
|
|
* @effects prod_build_with_skipped_extension_is_not_cached
|
|
*/
|
|
public function test_prod_does_not_cache_when_an_extension_was_skipped_during_merge(): void
|
|
{
|
|
$this->app['env'] = 'production';
|
|
app()->detectEnvironment(fn () => 'production');
|
|
|
|
$cssPath = $this->writeFixture('unreadable.css', '.a{color:red}');
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-unreadable' => $this->fakeExtension('ext-unreadable', 100, null, $cssPath),
|
|
]);
|
|
|
|
$svc = new class($this->moduleManager, $this->pluginManager) extends ExtensionBundleService
|
|
{
|
|
public string $failOn = '';
|
|
|
|
protected function readAssetSource(string $path): string|false
|
|
{
|
|
return $path === $this->failOn ? false : parent::readAssetSource($path);
|
|
}
|
|
};
|
|
$svc->failOn = $cssPath;
|
|
|
|
$this->assertSame('', $svc->getBundleFilePath('module', 'css', 424250));
|
|
$this->assertFileDoesNotExist(storage_path('app/ext-bundles/module.424250.css'));
|
|
|
|
// 원인이 사라지면 다음 요청이 정상 캐시한다 (매 요청 재시도)
|
|
$svc->failOn = '';
|
|
$path = $svc->getBundleFilePath('module', 'css', 424250);
|
|
|
|
$this->assertNotSame('', $path);
|
|
$this->assertStringEqualsFile($path, '.a{color:red}');
|
|
}
|
|
}
|