Files
Gnuboard7/tests/Unit/Services/ExtensionBundleServiceTest.php
T
HeuJung 5d14d01d50 fix(core): 디버그 라우트 그룹 게이트 일원화 + 확장 자산 CSS 상대 참조 치환
https://github.com/gnuboard/g7/issues/128 (제보자 glitter-gim) 과 그 부수의무
전수조사에서 확정한 결함군을 닫는다.

## 디버그 라우트 게이트 (F1~F5)

게이트가 핸들러 안에 흩어져 있어 8개 라우트 중 3개에만 붙어 있었다. 빠진 쪽에
`File::cleanDirectory(storage/debug-dump)` 를 수행하는 `DELETE clear` 가 있었고,
production·APP_DEBUG=false 에서도 미인증 200 으로 덤프 전체가 지워졌다. GET 4종은
User SPA catch-all 이 등록 순서상 앞서 가려 주고 있었을 뿐이라, 보호를 우연에 맡긴
구조였다.

게이트를 `bootstrap/app.php` 의 devtools 래퍼 한 곳(`debug.gate` 그룹 미들웨어)으로
올린다. 새 라우트는 자동으로 덮이고, 부착 지점이 하나라 훼손이 곧바로 드러난다.
`withRouting(channels:)` 인자가 함께 등록하던 게이트 없는 `/broadcasting/auth` 는
제거하고, 채널 정의 로드만 `BroadcastServiceProvider` 로 옮겨 웹소켓 킬스위치
우회로를 없앤다. catch-all 제외 패턴에는 예약 프리픽스를 전수 추가했다.

## 확장 자산 CSS 상대 참조 (범위 추가 — 결정)

`asset_url_mode=extensionless` 에서 자산 URL 이 쿼리 형태가 되면 브라우저의 기준
디렉토리가 `/api/{타입}/assets/` 로 잡혀, CSS 안의 `url('./woff2/f.woff2')` 가
존재하지 않는 주소가 된다. 사용자 템플릿 웹폰트 1건과 관리자 템플릿 국기 아이콘
약 500건이 이 상태였다. 병합 CSS 번들은 더 나빴다 — 상대 참조를 가진 확장을 번들에서
통째로 제외하고 있었는데, 번들 URL 이 내려오면 프론트는 개별 로딩을 아예 타지 않으므로
제외는 곧 그 확장 스타일이 하나도 적용되지 않음을 뜻했다.

둘 다 서빙 시점에 절대 자산 URL 로 치환한다. 최종 URL 은 런타임 모드와 캐시 버전이
정하므로 빌드 시점에는 알 수 없고, 동봉 자산은 제3자 산출물이라 원본을 손대면 상류
갱신마다 재작업이 된다. 개별 서빙 3경로와 병합 번들이 같은 해석 규칙을 공유한다 —
갈라지면 한쪽만 고쳐진 채 남는다.

이 결함군은 공통적으로 예외도 서버 로그 흔적도 남기지 않는다. 열린 엔드포인트가
정상 응답하는 것, 404 로 글꼴이 기본 서체가 되는 것이 유일한 증상이다.

## 회귀 잠금

되돌림 red 를 모집단 전원에 대해 실측했다 — 그룹 미들웨어 제거, 개별 게이트 재주입,
`channels:` 복원, catch-all 예외 제거, 컨트롤러 3원의 `fileResponse` 회귀, 번들
치환 제거가 각각 어느 테스트를 red 로 만드는지 확인하고 원상 복구했다.

게이트 부착 축은 행위 테스트로 대체할 수 없다 — 가려진 라우트는 행위상 "막힌 것" 과
구분되지 않으므로, `gatherMiddleware` 를 직접 보는 등록 계약 테스트를 둔다. 치환
배선도 같은 이유로 라우트 이름 규약에서 모집단을 도출하는 계약 검사를 둔다(손으로 적은
목록은 네 번째 경로가 생기는 순간 조용히 낡는다).
2026-09-03 07:42:28 +09:00

520 lines
21 KiB
PHP

<?php
namespace Tests\Unit\Services;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Services\ExtensionBundleService;
use Illuminate\Support\Facades\File;
use Mockery;
use Tests\TestCase;
/**
* 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 소실) 상태를 그대로 재현하기 위해서다.
*
* @param string $identifier 확장 식별자
* @param int $priority 로딩 우선순위
* @param array<string, mixed> $assets 매니페스트 assets 선언
* @param string $strategy 로딩 전략
*/
private function fakeExtensionWithAssets(string $identifier, int $priority, array $assets, string $strategy = 'global'): 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);
$ext->shouldReceive('getBuiltAssetAbsolutePaths')->andReturn(
array_map(fn () => $this->fixtureDir.'/missing-'.$identifier.'.out', $assets)
);
$ext->shouldReceive('getBuiltAssetPaths')->andReturn(
array_map(fn () => 'dist/css/module.css', $assets)
);
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'));
// 산출물은 하나도 존재하지 않는다 → 병합 결과는 빈 문자열.
// "선언 > 0 && 결과 0" 이 곧 컨트롤러의 503 조건이다.
$this->assertSame('', $service->getBundleFilePath('module', 'js', 12345));
}
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));
}
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);
}
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'));
}
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);
}
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);
}
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). 즉 제외 = 그 확장 스타일이 하나도 적용되지 않음
* 이었고, 오류도 로그 흔적도 화면 경고도 남지 않았다.
*/
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);
}
public function test_empty_bundle_returns_empty_path(): void
{
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
$path = $this->service()->getBundleFilePath('module', 'js', 12345);
$this->assertSame('', $path);
}
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})()');
// getActiveModules 는 두 번 호출될 수 있으므로 안정적으로 반환
$this->moduleManager->shouldReceive('getActiveModules')->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);
// 같은 version 재요청 → 동일 파일 (캐시 히트)
$path2 = $svc->getBundleFilePath('module', 'js', 999);
$this->assertSame($path1, $path2);
}
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');
}
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);
}
}
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'));
}
}