Merge pull request from gnuboard:HeuJung/issue644
HeuJung/issue644
This commit is contained in:
+1
-1
@@ -69,7 +69,7 @@
|
|||||||
- 초기 화면 파일을 새로 만드는 도중 짧은 시간 동안 기존 파일을 찾지 못할 수 있던 문제를 수정했습니다. 새 파일이 완성된 뒤에야 이전 파일을 정리합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
- 초기 화면 파일을 새로 만드는 도중 짧은 시간 동안 기존 파일을 찾지 못할 수 있던 문제를 수정했습니다. 새 파일이 완성된 뒤에야 이전 파일을 정리합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
- 캐시를 비운 뒤 정리 작업이 먼저 실행되면 아직 사용 중인 직전 초기 화면 파일이 삭제될 수 있던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
- 캐시를 비운 뒤 정리 작업이 먼저 실행되면 아직 사용 중인 직전 초기 화면 파일이 삭제될 수 있던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
- 확장 프로그램의 스크립트·스타일 묶음을 저장하지 못하는 환경에서 해당 요청이 서버 오류(500)가 되던 문제를 수정했습니다. 저장에 실패해도 화면에는 정상적으로 전달됩니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
- 확장 프로그램의 스크립트·스타일 묶음을 저장하지 못하는 환경에서 해당 요청이 서버 오류(500)가 되던 문제를 수정했습니다. 저장에 실패해도 화면에는 정상적으로 전달됩니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
- 배포 도중 확장 파일이 잠시 비면 내용이 빠진 빈 묶음이 정상 응답으로 전달되어, 한참 뒤 기능이 동작하지 않는 형태로만 드러나던 문제를 수정했습니다. 이제 그 상태를 오류로 알립니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
- 배포 도중 확장 파일이 잠시 비면 내용이 빠진 빈 묶음이 정상 응답으로 전달되어, 한참 뒤 기능이 동작하지 않는 형태로만 드러나던 문제를 수정했습니다. 이제 그 상태를 오류로 알립니다. 스타일이 비어 있는 확장만 설치된 사이트처럼 내용이 원래 없는 경우는 오류로 보지 않습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
- 일부 확장자(`.mjs`, `.webp`, `.otf`) 의 없는 파일을 요청하면 파일 대신 페이지 내용이 전달되어 화면이 깨지던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
- 일부 확장자(`.mjs`, `.webp`, `.otf`) 의 없는 파일을 요청하면 파일 대신 페이지 내용이 전달되어 화면이 깨지던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
- 초기 화면 파일을 새로 만들 때 파일 시스템이 일시적으로 이동을 거부하면 그 한 번으로 생성이 실패하던 문제를 수정했습니다. 이제 잠시 후 다시 시도하며, 사이트 동작에는 영향이 없지만 불필요한 실패 알림이 뜨던 상황이 사라집니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
- 초기 화면 파일을 새로 만들 때 파일 시스템이 일시적으로 이동을 거부하면 그 한 번으로 생성이 실패하던 문제를 수정했습니다. 이제 잠시 후 다시 시도하며, 사이트 동작에는 영향이 없지만 불필요한 실패 알림이 뜨던 상황이 사라집니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
- 설치 마법사 2단계(설치 환경 확인)가 Windows 사용자 계정 이름에 한글이 포함되어 있으면 빈 응답을 받아 `Unexpected end of JSON input` 오류로 더 진행되지 않던 문제를 수정했습니다. 계정 이름을 영문으로 바꾸지 않아도 설치할 수 있습니다. (sir.kr 커뮤니티의 FreeMax 님께서 제보해주셨습니다.)
|
- 설치 마법사 2단계(설치 환경 확인)가 Windows 사용자 계정 이름에 한글이 포함되어 있으면 빈 응답을 받아 `Unexpected end of JSON input` 오류로 더 진행되지 않던 문제를 수정했습니다. 계정 이름을 영문으로 바꾸지 않아도 설치할 수 있습니다. (sir.kr 커뮤니티의 FreeMax 님께서 제보해주셨습니다.)
|
||||||
|
|||||||
@@ -1304,6 +1304,29 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매니페스트가 **선언한** 프론트엔드 자산의 절대 경로를 반환합니다 (파일 존재 여부 무관).
|
||||||
|
*
|
||||||
|
* `getBuiltAssetAbsolutePaths()` 는 `file_exists()` 게이트라 소실된 산출물이 목록에서
|
||||||
|
* 사라진다. 배포 중 `dist` 가 잠깐 비는 상태를 "선언은 있는데 파일이 없다" 로 세려면
|
||||||
|
* 선언 축을 그대로 돌려주는 통로가 필요하다 — 이 메서드가 그 축이다.
|
||||||
|
*
|
||||||
|
* @return array<string, string> kind('js'|'css') => 절대 경로 (선언된 kind 만)
|
||||||
|
*/
|
||||||
|
public function getDeclaredAssetAbsolutePaths(): array
|
||||||
|
{
|
||||||
|
$assets = $this->getAssets();
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach (['js', 'css'] as $kind) {
|
||||||
|
if (! empty($assets[$kind]['output'])) {
|
||||||
|
$result[$kind] = $this->getModulePath().'/'.$assets[$kind]['output'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모듈 스토리지 드라이버 인스턴스 반환
|
* 모듈 스토리지 드라이버 인스턴스 반환
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1126,6 +1126,29 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매니페스트가 **선언한** 프론트엔드 자산의 절대 경로를 반환합니다 (파일 존재 여부 무관).
|
||||||
|
*
|
||||||
|
* `getBuiltAssetAbsolutePaths()` 는 `file_exists()` 게이트라 소실된 산출물이 목록에서
|
||||||
|
* 사라진다. 배포 중 `dist` 가 잠깐 비는 상태를 "선언은 있는데 파일이 없다" 로 세려면
|
||||||
|
* 선언 축을 그대로 돌려주는 통로가 필요하다 — 이 메서드가 그 축이다.
|
||||||
|
*
|
||||||
|
* @return array<string, string> kind('js'|'css') => 절대 경로 (선언된 kind 만)
|
||||||
|
*/
|
||||||
|
public function getDeclaredAssetAbsolutePaths(): array
|
||||||
|
{
|
||||||
|
$assets = $this->getAssets();
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach (['js', 'css'] as $kind) {
|
||||||
|
if (! empty($assets[$kind]['output'])) {
|
||||||
|
$result[$kind] = $this->getPluginPath().'/'.$assets[$kind]['output'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 플러그인이 설정 페이지를 가지고 있는지 확인합니다.
|
* 플러그인이 설정 페이지를 가지고 있는지 확인합니다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class PublicModuleController extends PublicBaseController
|
|||||||
*
|
*
|
||||||
* 병합 파일을 fileResponse 로 서빙한다(ETag/304/환경별 Cache-Control 재사용).
|
* 병합 파일을 fileResponse 로 서빙한다(ETag/304/환경별 Cache-Control 재사용).
|
||||||
* 디스크 캐시가 실패하면 메모리 병합 결과로 200 을 낸다(캐시는 최적화일 뿐이다).
|
* 디스크 캐시가 실패하면 메모리 병합 결과로 200 을 낸다(캐시는 최적화일 뿐이다).
|
||||||
* 에셋을 선언한 활성 확장이 0개면 빈 200, 선언은 있는데 결과가 비면 503 —
|
* 선언한 산출물이 소실·판독 불가면 503, 존재하되 비었으면(또는 선언 0) 빈 200 —
|
||||||
* 판정은 ServesExtensionBundles::bundleResponse() 단일 지점.
|
* 판정은 ServesExtensionBundles::bundleResponse() 단일 지점.
|
||||||
*
|
*
|
||||||
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
|
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class PublicPluginController extends PublicBaseController
|
|||||||
*
|
*
|
||||||
* 병합 파일을 fileResponse 로 서빙한다(ETag/304/환경별 Cache-Control 재사용).
|
* 병합 파일을 fileResponse 로 서빙한다(ETag/304/환경별 Cache-Control 재사용).
|
||||||
* 디스크 캐시가 실패하면 메모리 병합 결과로 200 을 낸다(캐시는 최적화일 뿐이다).
|
* 디스크 캐시가 실패하면 메모리 병합 결과로 200 을 낸다(캐시는 최적화일 뿐이다).
|
||||||
* 에셋을 선언한 활성 확장이 0개면 빈 200, 선언은 있는데 결과가 비면 503 —
|
* 선언한 산출물이 소실·판독 불가면 503, 존재하되 비었으면(또는 선언 0) 빈 200 —
|
||||||
* 판정은 ServesExtensionBundles::bundleResponse() 단일 지점.
|
* 판정은 ServesExtensionBundles::bundleResponse() 단일 지점.
|
||||||
*
|
*
|
||||||
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
|
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|||||||
* 2. 병합 결과는 있는데 디스크 캐시가 실패 → **메모리 결과를 그대로 200** 으로 서빙.
|
* 2. 병합 결과는 있는데 디스크 캐시가 실패 → **메모리 결과를 그대로 200** 으로 서빙.
|
||||||
* 디스크 캐시는 최적화이므로 쓰기 실패가 공개 엔드포인트의 500 이 되면 안 된다.
|
* 디스크 캐시는 최적화이므로 쓰기 실패가 공개 엔드포인트의 500 이 되면 안 된다.
|
||||||
* 3. 병합 결과가 비었음 →
|
* 3. 병합 결과가 비었음 →
|
||||||
* - 에셋을 선언한 활성 확장이 **0개**면 정상 → 빈 200
|
* - 선언한 산출물이 **소실·판독 불가**면 장애(배포 중 `dist` 가 잠깐 빔, 경로
|
||||||
* - 선언은 있는데 결과가 0 이면 장애(배포 중 `dist` 가 잠깐 빔, 경로 어긋남) →
|
* 어긋남) → **503**. 빈 200 으로 내보내면 프론트는 404 도 오류도 받지 못한 채
|
||||||
* **503**. 빈 200 으로 내보내면 프론트는 404 도 오류도 받지 못한 채 한참 뒤
|
* 한참 뒤 "Unknown action handler" 로 죽어, 원인이 번들이라는 사실이 드러나지 않는다.
|
||||||
* "Unknown action handler" 로 죽어, 원인이 번들이라는 사실이 드러나지 않는다.
|
* - 산출물이 **존재하되 비어 있으면** 정상 → 빈 200. 스타일 소스가 자리표시 주석뿐이라
|
||||||
|
* 0바이트 CSS 를 내보내는 확장은 정당한 상태다. 선언 축만으로 판정하면 그런 확장만
|
||||||
|
* 설치된 기본 구성이 통째로 503 이 되어 사용자 화면마다 실패 안내가 뜬다.
|
||||||
|
* - 선언이 **0개**여도 정상 → 빈 200
|
||||||
*/
|
*/
|
||||||
trait ServesExtensionBundles
|
trait ServesExtensionBundles
|
||||||
{
|
{
|
||||||
@@ -61,15 +64,17 @@ trait ServesExtensionBundles
|
|||||||
->header('Cache-Control', 'no-cache, private');
|
->header('Cache-Control', 'no-cache, private');
|
||||||
}
|
}
|
||||||
|
|
||||||
$declared = $bundleService->countAssetDeclaringExtensions($type, $kind);
|
$missing = $bundleService->findMissingDeclaredAssets($type, $kind);
|
||||||
|
|
||||||
if ($declared > 0) {
|
if ($missing !== []) {
|
||||||
// (3) 장애 — 선언은 있는데 결과가 0
|
// (3) 장애 — 선언한 산출물이 소실·판독 불가 (배포 중 dist 가 잠깐 빔, 경로 어긋남).
|
||||||
Log::error('확장 번들 병합 결과가 비었습니다 — 에셋을 선언한 활성 확장이 있습니다', [
|
// 어느 파일이 없는지를 함께 남긴다 — 선언 수만으로는 운영자가 조치 대상을 짚을 수 없다.
|
||||||
|
Log::error('확장 번들 병합 결과가 비었습니다 — 선언된 산출물이 없거나 읽을 수 없습니다', [
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'kind' => $kind,
|
'kind' => $kind,
|
||||||
'version' => $version,
|
'version' => $version,
|
||||||
'declared_extensions' => $declared,
|
'declared_extensions' => $bundleService->countAssetDeclaringExtensions($type, $kind),
|
||||||
|
'missing' => $missing,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response('', 503)
|
return response('', 503)
|
||||||
@@ -77,7 +82,8 @@ trait ServesExtensionBundles
|
|||||||
->header('Cache-Control', 'no-cache, private');
|
->header('Cache-Control', 'no-cache, private');
|
||||||
}
|
}
|
||||||
|
|
||||||
// (3) 정상 — 선언이 0개면 빈 번들이 맞다
|
// (3) 정상 — 선언이 0 이거나, 선언된 산출물이 전부 존재하되 비어 있다
|
||||||
|
// (스타일이 비어 있는 확장). 빈 번들이 맞다.
|
||||||
return response('', 200)->header('Content-Type', $mimeType);
|
return response('', 200)->header('Content-Type', $mimeType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-1
@@ -739,18 +739,36 @@ class SeoRenderer implements SeoRendererInterface
|
|||||||
return $resolved;
|
return $resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 템플릿 디렉토리의 절대 경로를 반환합니다.
|
||||||
|
*
|
||||||
|
* 테스트가 임시 디렉토리를 템플릿 루트로 쓸 수 있도록 분리한 seam 이다.
|
||||||
|
*
|
||||||
|
* @param string $identifier 템플릿 식별자
|
||||||
|
* @return string 템플릿 루트 절대 경로
|
||||||
|
*/
|
||||||
|
protected function templateRootPath(string $identifier): string
|
||||||
|
{
|
||||||
|
return base_path("templates/{$identifier}");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 템플릿의 CSS 에셋 URL 목록을 반환합니다.
|
* 템플릿의 CSS 에셋 URL 목록을 반환합니다.
|
||||||
*
|
*
|
||||||
* template.json의 assets.css 경로를 서빙 URL로 변환합니다.
|
* template.json의 assets.css 경로를 서빙 URL로 변환합니다.
|
||||||
* 예: "dist/css/components.css" → "/api/templates/assets/{id}/css/components.css"
|
* 예: "dist/css/components.css" → "/api/templates/assets/{id}/css/components.css"
|
||||||
*
|
*
|
||||||
|
* `assets.css` 는 **선언**일 뿐이라 산출물이 없을 수 있다. 없는 경로를 그대로 링크하면
|
||||||
|
* 봇 화면에서만 404 가 나고 일반 화면에는 흔적이 없다 — 서버 로그에도 남지 않아
|
||||||
|
* 운영자가 알 방법이 없다. 그래서 파일이 실재하는 경로만 싣는다(0바이트는 정상).
|
||||||
|
*
|
||||||
* @param string $templateIdentifier 템플릿 식별자
|
* @param string $templateIdentifier 템플릿 식별자
|
||||||
* @return array CSS URL 배열
|
* @return array CSS URL 배열
|
||||||
*/
|
*/
|
||||||
private function getTemplateCssUrls(string $templateIdentifier): array
|
private function getTemplateCssUrls(string $templateIdentifier): array
|
||||||
{
|
{
|
||||||
$templateJsonPath = base_path("templates/{$templateIdentifier}/template.json");
|
$templateRoot = $this->templateRootPath($templateIdentifier);
|
||||||
|
$templateJsonPath = $templateRoot.'/template.json';
|
||||||
if (! file_exists($templateJsonPath)) {
|
if (! file_exists($templateJsonPath)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -767,6 +785,12 @@ class SeoRenderer implements SeoRendererInterface
|
|||||||
|
|
||||||
$urls = [];
|
$urls = [];
|
||||||
foreach ($cssPaths as $cssPath) {
|
foreach ($cssPaths as $cssPath) {
|
||||||
|
// 선언한 파일이 실재할 때만 링크한다 — 없는 경로의 <link> 는 봇 화면에서만
|
||||||
|
// 404 가 되고 어디에도 흔적을 남기지 않는다
|
||||||
|
if (! is_file($templateRoot.'/'.$cssPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// dist/ 접두사 제거 (서빙 경로에서는 dist가 자동 추가됨)
|
// dist/ 접두사 제거 (서빙 경로에서는 dist가 자동 추가됨)
|
||||||
$servePath = preg_replace('#^dist/#', '', $cssPath);
|
$servePath = preg_replace('#^dist/#', '', $cssPath);
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ use Illuminate\Support\Facades\Log;
|
|||||||
* 경로는 절대경로 게터(`getBuiltAssetAbsolutePaths()`)를 재사용한다 —
|
* 경로는 절대경로 게터(`getBuiltAssetAbsolutePaths()`)를 재사용한다 —
|
||||||
* `ModuleService::getAssetFilePath()` 의 `base_path("modules/{id}/...")`
|
* `ModuleService::getAssetFilePath()` 의 `base_path("modules/{id}/...")`
|
||||||
* 하드코딩을 복제하지 않아야 `_bundled` 확장에서도 정확히 읽는다(제약 4).
|
* 하드코딩을 복제하지 않아야 `_bundled` 확장에서도 정확히 읽는다(제약 4).
|
||||||
|
* 소실 판정만 선언 축 게터(`getDeclaredAssetAbsolutePaths()`)를 쓴다 — 그 게터는
|
||||||
|
* `file_exists()` 게이트를 타지 않아 "선언은 있는데 파일이 없다" 를 셀 수 있다.
|
||||||
*
|
*
|
||||||
* @see TemplateComposer
|
* @see TemplateComposer
|
||||||
*/
|
*/
|
||||||
@@ -315,10 +317,13 @@ class ExtensionBundleService
|
|||||||
/**
|
/**
|
||||||
* 해당 타입에서 프론트엔드 에셋을 **선언한** 활성 확장 수를 반환합니다.
|
* 해당 타입에서 프론트엔드 에셋을 **선언한** 활성 확장 수를 반환합니다.
|
||||||
*
|
*
|
||||||
* "선언이 0 이라 빈 번들" (정상)과 "선언은 있는데 병합 결과가 0" (장애 — 배포 중
|
* 이 값은 **선언 축**이다 — 503 의 판정은 소실 축(`findMissingDeclaredAssets()`)이
|
||||||
* `dist` 가 잠깐 비었거나 경로가 어긋났다)을 구분하는 유일한 근거다. 구분하지 않으면
|
* 한다. 선언 축만으로 "선언 > 0 && 병합 결과 0 = 장애" 로 등치하면, 산출물이 존재하되
|
||||||
* 후자가 빈 200 으로 나가고, 프론트는 404 도 오류도 받지 못한 채 한참 뒤
|
* 비어 있는 정당한 상태(스타일이 비어 있는 확장)까지 배포 장애로 잡혀 그 확장만
|
||||||
* "Unknown action handler" 로 죽는다.
|
* 설치된 기본 구성이 통째로 503 이 된다.
|
||||||
|
*
|
||||||
|
* 선언 축은 로그 컨텍스트(운영자가 보는 "선언한 확장이 몇 개인가")와 화면 진단이
|
||||||
|
* 근거로 삼는다.
|
||||||
*
|
*
|
||||||
* 판정은 **kind 별**이다 — js 만 선언한 확장이 있는 상태에서 css 번들이 비는 것은
|
* 판정은 **kind 별**이다 — js 만 선언한 확장이 있는 상태에서 css 번들이 비는 것은
|
||||||
* 정상이므로, 그 경우까지 장애로 보면 정상 구성이 503 이 된다.
|
* 정상이므로, 그 경우까지 장애로 보면 정상 구성이 503 이 된다.
|
||||||
@@ -365,6 +370,57 @@ class ExtensionBundleService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선언된 산출물 중 **소실·판독 불가**한 것의 절대 경로 목록을 반환합니다.
|
||||||
|
*
|
||||||
|
* 503 의 근거는 선언이 아니라 이 소실 축이다. 선언만으로 판정하면 "선언됨 + 산출물이 존재하지만
|
||||||
|
* 0바이트"(스타일이 비어 있는 확장의 정당한 상태) 와 "선언됨 + 산출물 소실"(배포 중 dist 가 잠깐 빔)
|
||||||
|
* 이 구분되지 않아 정상 구성이 503 이 된다 — 번들 확장만 설치한 기본 구성 전부가 그랬다.
|
||||||
|
*
|
||||||
|
* 모집단은 countAssetDeclaringExtensions() 와 같다. 경로는 확장의 선언 축 게터로만 얻는다 —
|
||||||
|
* getBuiltAssetAbsolutePaths() 는 file_exists() 게이트라 부재를 셀 수 없고, base_path("modules"…)
|
||||||
|
* 직접 조립은 _bundled 확장에서 어긋난다.
|
||||||
|
*
|
||||||
|
* @param string $type 'module' | 'plugin'
|
||||||
|
* @param string $kind 'js' | 'css'
|
||||||
|
* @return list<string> 소실·판독 불가 산출물의 절대 경로 (없으면 빈 배열)
|
||||||
|
*/
|
||||||
|
public function findMissingDeclaredAssets(string $type, string $kind): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$extensions = $type === 'plugin'
|
||||||
|
? $this->pluginManager->getActivePlugins()
|
||||||
|
: $this->moduleManager->getActiveModules();
|
||||||
|
|
||||||
|
$missing = [];
|
||||||
|
|
||||||
|
foreach ($extensions as $extension) {
|
||||||
|
// global 전략만 번들 대상 — 병합 대상 모집단과 동일한 필터를 쓴다
|
||||||
|
if (($extension->getAssetLoadingConfig()['strategy'] ?? 'global') !== 'global') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $extension->getDeclaredAssetAbsolutePaths()[$kind] ?? null;
|
||||||
|
|
||||||
|
if ($path !== null && (! is_file($path) || ! is_readable($path))) {
|
||||||
|
$missing[] = $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $missing;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// 판정 자체가 실패하면 장애로 단정하지 않는다(선언 축 카운트와 같은 fail-open). 흔적은 error 로 —
|
||||||
|
// 출하 기본 로그 수준이 error 라 warning 은 기록되지 않는다.
|
||||||
|
Log::error('확장 에셋 소실 판정 실패', [
|
||||||
|
'type' => $type,
|
||||||
|
'kind' => $kind,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 현재 version 외의 오래된 번들 파일을 삭제하고 삭제 건수를 반환합니다.
|
* 현재 version 외의 오래된 번들 파일을 삭제하고 삭제 건수를 반환합니다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2098,11 +2098,11 @@ Cache-Control: public, max-age=31536000
|
|||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
_에러 응답 없음 — 공개 엔드포인트이며, 병합 대상이 없어도 빈 본문의 200(text/css)을 반환합니다._
|
_선언한 산출물이 소실·판독 불가일 때 **503**(빈 본문, `Cache-Control: no-cache, private`) — 배포 중 `dist` 가 잠깐 비었거나 경로가 어긋난 상태입니다. 에셋을 선언한 활성 확장이 없거나, 선언한 산출물이 전부 존재하되 비어 있으면(스타일 규칙이 아직 없는 확장) 정상 빈 200 입니다._
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
**설명** 활성 모듈들의 프론트엔드 CSS 를 서버에서 하나로 병합한 번들을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 활성 global 모듈 에셋이 없으면 빈 200(text/css) 응답을 반환하고, 있으면 병합 파일을 ETag·환경별 Cache-Control 과 함께 서빙합니다. 페이지가 모듈 스타일을 요청 1건으로 로드하도록 합니다.
|
**설명** 활성 모듈들의 프론트엔드 CSS 를 서버에서 하나로 병합한 번들을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 병합 결과가 있으면 ETag·환경별 Cache-Control 과 함께 서빙하고, 비어 있으면 그 이유에 따라 갈립니다 — 선언이 0 이거나 선언한 산출물이 전부 존재하되 비어 있으면 빈 200(text/css), 선언한 산출물이 소실·판독 불가면 503 입니다. 0바이트 CSS 는 스타일 규칙이 아직 없는 확장의 정당한 상태이므로 장애로 보지 않습니다. 페이지가 모듈 스타일을 요청 1건으로 로드하도록 합니다.
|
||||||
|
|
||||||
|
|
||||||
### GET /api/modules/bundle.js
|
### GET /api/modules/bundle.js
|
||||||
@@ -2140,11 +2140,11 @@ Cache-Control: public, max-age=31536000
|
|||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
_에러 응답 없음 — 공개 엔드포인트이며, 병합 대상이 없어도 빈 본문의 200(text/javascript)을 반환합니다._
|
_선언한 산출물이 소실·판독 불가일 때 **503**(빈 본문, `Cache-Control: no-cache, private`) — 배포 중 `dist` 가 잠깐 비었거나 경로가 어긋난 상태입니다. 에셋을 선언한 활성 확장이 없거나, 선언한 산출물이 전부 존재하되 비어 있으면(스타일 규칙이 아직 없는 확장) 정상 빈 200 입니다._
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
**설명** 활성 모듈들의 프론트엔드 IIFE JS 를 서버에서 하나로 병합한 번들을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 활성 global 모듈 에셋이 없으면 빈 200(text/javascript) 응답을 반환하고(프론트는 빈 스크립트 로드로 무해), 있으면 병합 파일을 ETag·환경별 Cache-Control 과 함께 서빙합니다. 프론트는 `G7Config.bundleUrls` 를 읽어 이 번들을 로드합니다.
|
**설명** 활성 모듈들의 프론트엔드 IIFE JS 를 서버에서 하나로 병합한 번들을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 병합 결과가 있으면 ETag·환경별 Cache-Control 과 함께 서빙하고, 비어 있으면 그 이유에 따라 갈립니다 — 선언이 0 이거나 선언한 산출물이 전부 존재하되 비어 있으면 빈 200(text/javascript, 프론트는 빈 스크립트 로드로 무해), 선언한 산출물이 소실·판독 불가면 503 입니다. 프론트는 `G7Config.bundleUrls` 를 읽어 이 번들을 로드합니다.
|
||||||
|
|
||||||
|
|
||||||
### GET /api/modules/bundle/css
|
### GET /api/modules/bundle/css
|
||||||
|
|||||||
@@ -2382,11 +2382,11 @@ Cache-Control: public, max-age=31536000
|
|||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
_에러 응답 없음 — 공개 조회이며 요청 파라미터가 없고, 활성 에셋이 하나도 없는 경우에도 빈 200 을 반환합니다._
|
_선언한 산출물이 소실·판독 불가일 때 **503**(빈 본문, `Cache-Control: no-cache, private`) — 배포 중 `dist` 가 잠깐 비었거나 경로가 어긋난 상태입니다. 에셋을 선언한 활성 확장이 없거나, 선언한 산출물이 전부 존재하되 비어 있으면(스타일 규칙이 아직 없는 확장) 정상 빈 200 입니다._
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
**설명** 활성 플러그인들의 프론트엔드 CSS 를 서버에서 하나로 병합한 번들을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 활성 global 플러그인 에셋이 없으면 빈 200(text/css) 응답을 반환하고, 있으면 병합 파일을 ETag·환경별 Cache-Control 과 함께 서빙합니다. 페이지가 플러그인 스타일을 요청 1건으로 로드하도록 합니다.
|
**설명** 활성 플러그인들의 프론트엔드 CSS 를 서버에서 하나로 병합한 번들을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 판정은 모듈 번들과 같은 단일 지점을 공유합니다 — 선언이 0 이거나 선언한 산출물이 전부 존재하되 비어 있으면 빈 200(text/css), 선언한 산출물이 소실·판독 불가면 503, 병합 결과가 있으면 ETag·환경별 Cache-Control 과 함께 서빙합니다. 페이지가 플러그인 스타일을 요청 1건으로 로드하도록 합니다.
|
||||||
|
|
||||||
|
|
||||||
### GET /api/plugins/bundle.js
|
### GET /api/plugins/bundle.js
|
||||||
@@ -2433,11 +2433,11 @@ Cache-Control: public, max-age=31536000
|
|||||||
|
|
||||||
**에러 응답**
|
**에러 응답**
|
||||||
|
|
||||||
_에러 응답 없음 — 공개 조회이며 요청 파라미터가 없고, 활성 에셋이 하나도 없는 경우에도 빈 200 을 반환합니다._
|
_선언한 산출물이 소실·판독 불가일 때 **503**(빈 본문, `Cache-Control: no-cache, private`) — 배포 중 `dist` 가 잠깐 비었거나 경로가 어긋난 상태입니다. 에셋을 선언한 활성 확장이 없거나, 선언한 산출물이 전부 존재하되 비어 있으면(스타일 규칙이 아직 없는 확장) 정상 빈 200 입니다._
|
||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
**설명** 활성 플러그인들의 프론트엔드 IIFE JS 를 서버에서 하나로 병합한 번들을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 활성 global 플러그인 에셋이 없으면 빈 200(text/javascript) 응답을 반환하고, 있으면 병합 파일을 ETag·환경별 Cache-Control 과 함께 서빙합니다. 프론트는 `G7Config.bundleUrls` 를 읽어 이 번들을 로드합니다.
|
**설명** 활성 플러그인들의 프론트엔드 IIFE JS 를 서버에서 하나로 병합한 번들을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 판정은 모듈 번들과 같은 단일 지점을 공유합니다 — 선언이 0 이거나 선언한 산출물이 전부 존재하되 비어 있으면 빈 200(text/javascript), 선언한 산출물이 소실·판독 불가면 503, 병합 결과가 있으면 ETag·환경별 Cache-Control 과 함께 서빙합니다. 프론트는 `G7Config.bundleUrls` 를 읽어 이 번들을 로드합니다.
|
||||||
|
|
||||||
|
|
||||||
### GET /api/plugins/bundle/css
|
### GET /api/plugins/bundle/css
|
||||||
|
|||||||
@@ -823,7 +823,7 @@ SEO 엔진은 컴포넌트 지식을 갖지 않습니다. 모든 컴포넌트→
|
|||||||
| `text_props` | string[] | 텍스트 추출 우선순위 (예: `["text", "label", "value", "title"]`) |
|
| `text_props` | string[] | 텍스트 추출 우선순위 (예: `["text", "label", "value", "title"]`) |
|
||||||
| `attr_map` | object | props→HTML 속성 매핑 (예: `{"className": "class", "htmlFor": "for"}`) |
|
| `attr_map` | object | props→HTML 속성 매핑 (예: `{"className": "class", "htmlFor": "for"}`) |
|
||||||
| `allowed_attrs` | string[] | 허용 HTML 속성 목록 (목록에 없는 속성은 출력 안됨) |
|
| `allowed_attrs` | string[] | 허용 HTML 속성 목록 (목록에 없는 속성은 출력 안됨) |
|
||||||
| `stylesheets` | string[] | 외부 CSS URL (예: Font Awesome CDN) |
|
| `stylesheets` | string[] | 추가 CSS URL |
|
||||||
| `self_closing` | string[] | 셀프 클로징 태그 목록 (예: `["img", "input", "hr", "br"]`) |
|
| `self_closing` | string[] | 셀프 클로징 태그 목록 (예: `["img", "input", "hr", "br"]`) |
|
||||||
| `component_map` | object | 컴포넌트명 → HTML 태그 매핑 |
|
| `component_map` | object | 컴포넌트명 → HTML 태그 매핑 |
|
||||||
| `render_modes` | object | 렌더 모드 정의 |
|
| `render_modes` | object | 렌더 모드 정의 |
|
||||||
@@ -1490,6 +1490,11 @@ SeoRenderer.render()
|
|||||||
└─ View::make('seo', [..., 'stylesheets' => config.stylesheets])
|
└─ View::make('seo', [..., 'stylesheets' => config.stylesheets])
|
||||||
```
|
```
|
||||||
|
|
||||||
|
봇 화면의 `<link>` 에는 활성 사용자 템플릿의 `template.json` `assets.css` 도 실린다. 그 선언은
|
||||||
|
**파일이 실재할 때만** 링크한다 — 선언은 있는데 산출물이 없는 경로를 그대로 실으면 봇 화면에서만
|
||||||
|
404 가 나고 일반 화면에는 흔적이 없다. 서버 로그에도 남지 않으므로 운영자가 알 방법이 없다.
|
||||||
|
내용이 비어 있는 파일(0바이트)은 실재하므로 링크한다.
|
||||||
|
|
||||||
### navigate 핸들러 링크 자동 생성
|
### navigate 핸들러 링크 자동 생성
|
||||||
|
|
||||||
SEO 렌더링 시 `navigate`/`openWindow` 핸들러가 정의된 컴포넌트에 `<a href="...">` 하이퍼링크를 자동 생성합니다. 검색 엔진 봇이 내부 페이지를 발견할 수 있도록 링크 구조를 자동 형성합니다.
|
SEO 렌더링 시 `navigate`/`openWindow` 핸들러가 정의된 컴포넌트에 `<a href="...">` 하이퍼링크를 자동 생성합니다. 검색 엔진 봇이 내부 페이지를 발견할 수 있도록 링크 구조를 자동 형성합니다.
|
||||||
|
|||||||
@@ -185,6 +185,13 @@ manifest 는 배포물), 코어가 활성 확장 전체의 선언을 모아 allo
|
|||||||
| `loading.strategy` | 로딩 전략 (global, layout, lazy) |
|
| `loading.strategy` | 로딩 전략 (global, layout, lazy) |
|
||||||
| `loading.priority` | 로드 우선순위 (낮을수록 먼저) |
|
| `loading.priority` | 로드 우선순위 (낮을수록 먼저) |
|
||||||
|
|
||||||
|
`assets.js` · `assets.css` 는 **객체**이며 `output` 을 가져야 합니다. 목록형(`"js": ["dist/js/module.iife.js"]`)은
|
||||||
|
어느 소비자도 읽지 않아 **그 자산이 영영 로드되지 않는데, 오류도 경고도 남지 않습니다.**
|
||||||
|
|
||||||
|
선언한 `output` 이 가리키는 파일은 디스크에 실재해야 합니다. 내용이 비어 있는 것(0바이트)은
|
||||||
|
정상입니다 — 스타일 규칙이 아직 없는 확장이 CSS 를 선언하는 것은 어긋남이 아닙니다. 정적 검사가
|
||||||
|
번들 확장 전수를 대상으로 두 조건(객체 형식 · 산출물 실재)을 확인합니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 모듈 프론트엔드 구조
|
## 모듈 프론트엔드 구조
|
||||||
@@ -508,11 +515,11 @@ GET /api/plugins/bundle.css?v={version}
|
|||||||
| `\n;\n` 구분자 | IIFE 사이는 `\n;\n`(JS)/`\n`(CSS). 미사용 시 ASI 붕괴 → 전체 파싱 에러 | `extension-bundle-concat-separator` |
|
| `\n;\n` 구분자 | IIFE 사이는 `\n;\n`(JS)/`\n`(CSS). 미사용 시 ASI 붕괴 → 전체 파싱 에러 | `extension-bundle-concat-separator` |
|
||||||
| 소스맵 | prod strip, dev 는 개별 에셋 서빙 절대 URL 로 rewrite | - |
|
| 소스맵 | prod strip, dev 는 개별 에셋 서빙 절대 URL 로 rewrite | - |
|
||||||
| same-origin | 번들 URL 은 `/api/...` 만 (CDN 금지 — gdpr preblocker 자기차단 방지) | `extension-bundle-url-same-origin` |
|
| same-origin | 번들 URL 은 `/api/...` 만 (CDN 금지 — gdpr preblocker 자기차단 방지) | `extension-bundle-url-same-origin` |
|
||||||
| 절대경로 게터 | `getBuiltAssetAbsolutePaths()` 사용. `base_path("modules"\|"plugins")` 직접 조립 금지 | `extension-bundle-asset-path-getter` |
|
| 절대경로 게터 | `getBuiltAssetAbsolutePaths()` 사용. `base_path("modules"\|"plugins")` 직접 조립 금지. 소실 판정만 선언 축 게터 `getDeclaredAssetAbsolutePaths()` | `extension-bundle-asset-path-getter` |
|
||||||
| 확장별 try/catch | 파일 읽기 실패 시 해당 확장만 skip, 나머지 병합 지속 | (메모리+회귀테스트) |
|
| 확장별 try/catch | 파일 읽기 실패 시 해당 확장만 skip, 나머지 병합 지속 | (메모리+회귀테스트) |
|
||||||
| CSS url() | 상대 `url()`·`@import` 참조는 그 확장의 절대 자산 URL 로 **치환**해 병합. 병합본의 주소는 어느 확장의 dist 디렉토리도 아니라 상대 해석이 반드시 어긋난다 | (계약 테스트) |
|
| CSS url() | 상대 `url()`·`@import` 참조는 그 확장의 절대 자산 URL 로 **치환**해 병합. 병합본의 주소는 어느 확장의 dist 디렉토리도 아니라 상대 해석이 반드시 어긋난다 | (계약 테스트) |
|
||||||
| 디스크 캐시 fail-soft | 캐시 쓰기 실패는 **500 이 아니다** — 메모리 병합 결과를 그대로 200 으로 서빙 | (계약 테스트) |
|
| 디스크 캐시 fail-soft | 캐시 쓰기 실패는 **500 이 아니다** — 메모리 병합 결과를 그대로 200 으로 서빙 | (계약 테스트) |
|
||||||
| 빈 번들 판정 | 선언 0개면 빈 200, 선언은 있는데 결과가 0이면 **503** | (계약 테스트) |
|
| 빈 번들 판정 | 선언한 산출물이 소실·판독 불가면 **503**, 존재하되 비었으면 빈 200 (선언 0 도 빈 200) | (계약 테스트) |
|
||||||
|
|
||||||
### 병합 CSS 의 상대 참조
|
### 병합 CSS 의 상대 참조
|
||||||
|
|
||||||
@@ -531,11 +538,19 @@ GET /api/plugins/bundle.css?v={version}
|
|||||||
| 상태 | 판정 | 응답 |
|
| 상태 | 판정 | 응답 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 에셋을 선언한 활성 확장이 0개 | 정상 | 빈 200 |
|
| 에셋을 선언한 활성 확장이 0개 | 정상 | 빈 200 |
|
||||||
| 선언은 있는데 병합 결과가 0 | 장애 (배포 중 `dist` 가 잠깐 빔, 경로 어긋남) | **503** + `Log::error` |
|
| 선언은 있고 그 산출물이 **전부 존재**하되 비어 있음 | 정상 (스타일이 비어 있는 확장) | 빈 200 |
|
||||||
|
| 선언한 산출물이 **소실·판독 불가** | 장애 (배포 중 `dist` 가 잠깐 빔, 경로 어긋남) | **503** + `Log::error`(소실 경로 목록) |
|
||||||
|
|
||||||
구분하지 않으면 후자가 빈 200 으로 나가고, 프론트는 404 도 오류도 받지 못한 채 한참 뒤 "Unknown action handler" 로 죽는다 — 그 시점에는 원인이 번들이라는 사실이 화면에도 로그에도 남아 있지 않다. 판정은 **kind 별**이다(js 만 선언한 확장이 있는 상태에서 css 번들이 비는 것은 정상).
|
장애를 정상으로 흘리면 프론트는 404 도 오류도 받지 못한 채 한참 뒤 "Unknown action handler" 로 죽는다 — 그 시점에는 원인이 번들이라는 사실이 화면에도 로그에도 남아 있지 않다. 반대로 정상을 장애로 잡으면 스타일 소스가 자리표시 주석뿐인 확장만 설치된 기본 구성이 통째로 503 이 되어 **사용자 화면마다 실패 안내가 뜬다.** 판정은 **kind 별**이다(js 만 선언한 확장이 있는 상태에서 css 번들이 비는 것은 정상).
|
||||||
|
|
||||||
"선언" 의 근거는 manifest 의 `assets.{kind}.output` 이며 **산출물 파일의 존재를 보지 않는다.** 병합 경로가 쓰는 게터들(`getOrderedGlobalAssetPaths()` · `hasAssets()` · `getBuiltAssetPaths()`)은 모두 `file_exists()` 로 거르므로, 그 경로로 선언 수를 세면 "`dist` 가 잠깐 빔" 이 곧 "선언 0" 이 되어 **막으려던 상태가 정상(빈 200)으로 판정된다.** 선언과 산출은 다른 축이고, 이 판정이 재는 것은 선언 축이다.
|
두 축이 다르다는 점이 핵심이다.
|
||||||
|
|
||||||
|
- **선언 축**의 근거는 manifest 의 `assets.{kind}.output` 이며 **산출물 파일의 존재를 보지 않는다.** 병합 경로가 쓰는 게터들(`getOrderedGlobalAssetPaths()` · `hasAssets()` · `getBuiltAssetPaths()`)은 모두 `file_exists()` 로 거르므로, 그 경로로 선언 수를 세면 "`dist` 가 잠깐 빔" 이 곧 "선언 0" 이 된다. 이 축은 로그 컨텍스트와 진단이 근거로 삼는다.
|
||||||
|
- **소실 축**은 선언된 산출물 중 실제로 없거나 읽을 수 없는 것을 센다. 경로는 `getDeclaredAssetAbsolutePaths()` 로 얻는다 — 존재 게이트가 없어야 부재를 셀 수 있다. **503 의 판정은 이 축이 한다.**
|
||||||
|
|
||||||
|
0바이트 산출물은 정당한 상태다. 스타일 규칙이 아직 없는 확장이 CSS 를 선언하는 것은 어긋남이 아니며, 그 상태를 배포 장애로 등치하면 정상 사이트가 서비스 불능으로 보고된다.
|
||||||
|
|
||||||
|
빈 번들은 정적 게시 대상이 아니다(게시할 사본이 없다). 그 결과 `AssetUrl::extensionBundle()` 이 정적 URL 대신 API URL 을 방출하므로 브라우저에는 정적 404 폴백이 생기지 않고, 그 API 가 위 표대로 빈 200 을 낸다.
|
||||||
|
|
||||||
두 판정은 모듈·플러그인 컨트롤러가 **공유하는 단일 지점**(`ServesExtensionBundles::bundleResponse()`)에 둔다. 각자 구현하면 한쪽만 고쳐진 채 다른 쪽이 옛 동작으로 남는다.
|
두 판정은 모듈·플러그인 컨트롤러가 **공유하는 단일 지점**(`ServesExtensionBundles::bundleResponse()`)에 둔다. 각자 구현하면 한쪽만 고쳐진 채 다른 쪽이 옛 동작으로 남는다.
|
||||||
|
|
||||||
|
|||||||
@@ -885,7 +885,7 @@ loader.config({
|
|||||||
| 필드 | 타입 | 설명 |
|
| 필드 | 타입 | 설명 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `id` | `string` | 중복 누적을 막는 식별자 (필수) |
|
| `id` | `string` | 중복 누적을 막는 식별자 (필수) |
|
||||||
| `label` | `string` | 짧은 항목명 — 여러 건 합산 표시에 쓰인다 |
|
| `label` | `string` | 짧은 항목명 — 여러 건 합산 표시에 쓰인다. **사용자 어휘**여야 한다: 내부 구분 키·식별자를 그대로 넘기면 배너에 「module을(를) 불러오지 못했습니다」처럼 해석 불가한 문구가 노출된다 |
|
||||||
| `message` | `string?` | 사용자에게 보일 안내 문장. 생략하면 `label` 기반 기본 문구 |
|
| `message` | `string?` | 사용자에게 보일 안내 문장. 생략하면 `label` 기반 기본 문구 |
|
||||||
| `retry` | `() => Promise<void> \| void` | 생략하면 [다시 시도] 버튼을 렌더하지 않는다 |
|
| `retry` | `() => Promise<void> \| void` | 생략하면 [다시 시도] 버튼을 렌더하지 않는다 |
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
- 초기 화면 파일 생성이 거듭 실패할 때 관리자 대시보드에 표시되는 안내의 일본어 번역을 추가했습니다 — 폴더 권한·디스크 공간·캐시 저장소 중 어느 쪽이 원인인지에 따라 다른 안내가 일본어 로케일에서 표시됩니다.
|
- 초기 화면 파일 생성이 거듭 실패할 때 관리자 대시보드에 표시되는 안내의 일본어 번역을 추가했습니다 — 폴더 권한·디스크 공간·캐시 저장소 중 어느 쪽이 원인인지에 따라 다른 안내가 일본어 로케일에서 표시됩니다.
|
||||||
- 리버스 프록시 뒤에서 신뢰 프록시가 지정되지 않았을 때 관리자 대시보드에 표시되는 안내의 일본어 번역을 추가했습니다.
|
- 리버스 프록시 뒤에서 신뢰 프록시가 지정되지 않았을 때 관리자 대시보드에 표시되는 안내의 일본어 번역을 추가했습니다.
|
||||||
- 사이트 설정 문제로 화면 구성 파일이 차단됐을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다 — 방문자 쪽 문제가 아니라는 안내가 일본어 로케일에서 표시됩니다.
|
- 사이트 설정 문제로 화면 구성 파일이 차단됐을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다 — 방문자 쪽 문제가 아니라는 안내가 일본어 로케일에서 표시됩니다.
|
||||||
|
- 확장 프로그램의 스타일 묶음을 끝내 불러오지 못했을 때 안내에 표시되는 항목명의 일본어 번역을 추가했습니다 — 종전에는 그 자리에 내부 구분 이름이 그대로 보였고, 이제 「모듈 스타일」·「플러그인 스타일」에 해당하는 일본어가 표시됩니다.
|
||||||
|
|
||||||
## [1.0.8] - 2026-08-24
|
## [1.0.8] - 2026-08-24
|
||||||
|
|
||||||
|
|||||||
@@ -4,5 +4,7 @@
|
|||||||
"retry": "再試行",
|
"retry": "再試行",
|
||||||
"retrying": "再試行中...",
|
"retrying": "再試行中...",
|
||||||
"close": "閉じる",
|
"close": "閉じる",
|
||||||
"retry_failed": "再試行しましたが失敗しました。しばらく後に再度お試しください。"
|
"retry_failed": "再試行しましたが失敗しました。しばらく後に再度お試しください。",
|
||||||
|
"module_styles": "モジュールスタイル",
|
||||||
|
"plugin_styles": "プラグインスタイル"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"load_failed": "Could not load {label}.",
|
"load_failed": "Could not load {label}.",
|
||||||
"load_failed_multiple": "Could not load {count} items.",
|
"load_failed_multiple": "Could not load {count} items.",
|
||||||
|
"module_styles": "module styles",
|
||||||
|
"plugin_styles": "plugin styles",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"retrying": "Retrying...",
|
"retrying": "Retrying...",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"load_failed": "{label}을(를) 불러오지 못했습니다.",
|
"load_failed": "{label}을(를) 불러오지 못했습니다.",
|
||||||
"load_failed_multiple": "{count}개 항목을 불러오지 못했습니다.",
|
"load_failed_multiple": "{count}개 항목을 불러오지 못했습니다.",
|
||||||
|
"module_styles": "모듈 스타일",
|
||||||
|
"plugin_styles": "플러그인 스타일",
|
||||||
"retry": "다시 시도",
|
"retry": "다시 시도",
|
||||||
"retrying": "다시 시도 중...",
|
"retrying": "다시 시도 중...",
|
||||||
"close": "닫기",
|
"close": "닫기",
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
|
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
|
||||||
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
|
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 설정 파일의 프론트엔드 자산 항목이 문서화된 형식과 다른 목록형으로 적혀 있어 예제로 삼으면 스크립트가 로드되지 않는 형태였습니다. 선언할 자산이 없어 항목을 제거했습니다.
|
||||||
|
|
||||||
## [0.1.1] - 2026-08-10
|
## [0.1.1] - 2026-08-10
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -79,7 +79,10 @@ _프론트 에셋이 없습니다._
|
|||||||
|
|
||||||
그래서 반영이 `php artisan module:update gnuboard7-hello_module --force` 하나로 끝납니다.
|
그래서 반영이 `php artisan module:update gnuboard7-hello_module --force` 하나로 끝납니다.
|
||||||
JS 를 더하면 그때 빌드(`module:build --production`)·`dist/` 커밋·전역 진입점 셋이 함께
|
JS 를 더하면 그때 빌드(`module:build --production`)·`dist/` 커밋·전역 진입점 셋이 함께
|
||||||
필요해집니다.
|
필요해집니다. 그때 `module.json` 에는 `assets.js.output` **객체 형식**으로 선언합니다
|
||||||
|
(`"assets": { "js": { "entry": "resources/js/index.ts", "output": "dist/js/....iife.js" } }`).
|
||||||
|
목록형(`"js": ["..."]`)은 어느 소비자도 읽지 않아 그 스크립트가 영영 로드되지 않는데,
|
||||||
|
오류도 경고도 남지 않습니다.
|
||||||
|
|
||||||
구동에 필요한 제3자 자산은 외부 CDN 에서 받지 않고 확장이 동봉합니다 — CDN 도달 실패는 예외도
|
구동에 필요한 제3자 자산은 외부 CDN 에서 받지 않고 확장이 동봉합니다 — CDN 도달 실패는 예외도
|
||||||
서버 로그도 남기지 않고 화면 기능만 조용히 사라지기 때문입니다.
|
서버 로그도 남기지 않고 화면 기능만 조용히 사라지기 때문입니다.
|
||||||
|
|||||||
@@ -16,9 +16,5 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"modules": {},
|
"modules": {},
|
||||||
"plugins": {}
|
"plugins": {}
|
||||||
},
|
|
||||||
"assets": {
|
|
||||||
"js": [],
|
|
||||||
"css": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-14
File diff suppressed because one or more lines are too long
@@ -174,6 +174,22 @@ export function getAssetFailures(): AssetFailure[] {
|
|||||||
return Array.from(failures.values());
|
return Array.from(failures.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자산 실패 안내에 쓰는 사용자 어휘를 번역합니다 (배너 밖 호출자용).
|
||||||
|
*
|
||||||
|
* 항목명(label)은 사용자 어휘여야 한다 — 내부 구분 키·식별자를 그대로 넘기면 배너에
|
||||||
|
* "module을(를) 불러오지 못했습니다" 처럼 내부 이름이 노출된다.
|
||||||
|
*
|
||||||
|
* @param key 번역 키
|
||||||
|
* @param fallback 번역 엔진 부재 시 사용할 문구
|
||||||
|
* @param params 치환 파라미터
|
||||||
|
* @return string 번역된 문자열
|
||||||
|
* @since engine-v1.64.7
|
||||||
|
*/
|
||||||
|
export function assetText(key: string, fallback: string, params?: Record<string, string | number>): string {
|
||||||
|
return t(key, fallback, params);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 등록된 실패 중 재시도 가능한 것을 모두 재시도합니다.
|
* 등록된 실패 중 재시도 가능한 것을 모두 재시도합니다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -9,10 +9,26 @@
|
|||||||
import { createLogger } from '../utils/Logger';
|
import { createLogger } from '../utils/Logger';
|
||||||
import { loadScriptWithRetry, loadStylesheetWithRetry } from '../template-engine/networkResilience';
|
import { loadScriptWithRetry, loadStylesheetWithRetry } from '../template-engine/networkResilience';
|
||||||
import { convertToCurrentMode, staticToLegacy } from '../support/assetUrl';
|
import { convertToCurrentMode, staticToLegacy } from '../support/assetUrl';
|
||||||
import { notifyAssetFailure } from '../assets/AssetFailureNotice';
|
import { assetText, notifyAssetFailure } from '../assets/AssetFailureNotice';
|
||||||
|
|
||||||
const logger = createLogger('ModuleAssetLoader');
|
const logger = createLogger('ModuleAssetLoader');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 병합 CSS 번들의 실패 안내 항목명 (사용자 어휘).
|
||||||
|
*
|
||||||
|
* 번들 구분 키(`module`/`plugin`)는 내부 이름이다. 그대로 배너에 넘기면
|
||||||
|
* "module을(를) 불러오지 못했습니다" 처럼 사용자가 해석할 수 없는 문구가 된다.
|
||||||
|
*
|
||||||
|
* @param key 번들 구분 키 ('module' | 'plugin')
|
||||||
|
* @return string 사용자 어휘 항목명
|
||||||
|
* @since engine-v1.64.7
|
||||||
|
*/
|
||||||
|
function bundleCssLabel(key: string): string {
|
||||||
|
return key === 'plugin'
|
||||||
|
? assetText('core.assets.plugin_styles', '플러그인 스타일')
|
||||||
|
: assetText('core.assets.module_styles', '모듈 스타일');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모듈 에셋 정보 인터페이스
|
* 모듈 에셋 정보 인터페이스
|
||||||
*/
|
*/
|
||||||
@@ -321,7 +337,7 @@ export class ModuleAssetLoader {
|
|||||||
// 항상 실패한다 — 버튼은 있는데 아무것도 고치지 못하는 상태가 된다.
|
// 항상 실패한다 — 버튼은 있는데 아무것도 고치지 못하는 상태가 된다.
|
||||||
this.surfaceCssFailure(
|
this.surfaceCssFailure(
|
||||||
`bundle-${key}`,
|
`bundle-${key}`,
|
||||||
key,
|
bundleCssLabel(key),
|
||||||
legacyUrl !== null ? convertToCurrentMode(legacyUrl) : url,
|
legacyUrl !== null ? convertToCurrentMode(legacyUrl) : url,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import { ModuleAssetLoader, type ModuleAsset } from '../ModuleAssetLoader';
|
import { ModuleAssetLoader, parseBundleUrlsFromConfig, type ModuleAsset } from '../ModuleAssetLoader';
|
||||||
|
import { clearAllAssetFailures, getAssetFailures } from '../../assets/AssetFailureNotice';
|
||||||
|
|
||||||
describe('ModuleAssetLoader', () => {
|
describe('ModuleAssetLoader', () => {
|
||||||
let loader: ModuleAssetLoader;
|
let loader: ModuleAssetLoader;
|
||||||
@@ -310,6 +311,9 @@ describe('ModuleAssetLoader', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('loadBundle (서버측 병합 번들)', () => {
|
describe('loadBundle (서버측 병합 번들)', () => {
|
||||||
|
/**
|
||||||
|
* @effects loadbundle_single_script_single_link_append
|
||||||
|
*/
|
||||||
it('단일 script + 단일 link 를 1개씩만 append 한다', async () => {
|
it('단일 script + 단일 link 를 1개씩만 append 한다', async () => {
|
||||||
const scriptAppends: string[] = [];
|
const scriptAppends: string[] = [];
|
||||||
const linkAppends: string[] = [];
|
const linkAppends: string[] = [];
|
||||||
@@ -350,6 +354,9 @@ describe('ModuleAssetLoader', () => {
|
|||||||
appendSpy.mockRestore();
|
appendSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @effects loadbundle_dedup_guard_no_double_load
|
||||||
|
*/
|
||||||
it('같은 key 를 중복 로드하지 않는다 (중복 가드)', async () => {
|
it('같은 key 를 중복 로드하지 않는다 (중복 가드)', async () => {
|
||||||
let scriptCount = 0;
|
let scriptCount = 0;
|
||||||
// 실제 DOM 에 삽입해 element id 가 등록되어야 getElementById 중복 가드가 동작
|
// 실제 DOM 에 삽입해 element id 가 등록되어야 getElementById 중복 가드가 동작
|
||||||
@@ -370,6 +377,9 @@ describe('ModuleAssetLoader', () => {
|
|||||||
appendSpy.mockRestore();
|
appendSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @effects loadbundle_null_urls_noop
|
||||||
|
*/
|
||||||
it('jsUrl/cssUrl 모두 null 이면 no-op', async () => {
|
it('jsUrl/cssUrl 모두 null 이면 no-op', async () => {
|
||||||
const appendSpy = vi.spyOn(document.head, 'appendChild');
|
const appendSpy = vi.spyOn(document.head, 'appendChild');
|
||||||
await expect(loader.loadBundle('module', null, null)).resolves.toBeUndefined();
|
await expect(loader.loadBundle('module', null, null)).resolves.toBeUndefined();
|
||||||
@@ -379,6 +389,9 @@ describe('ModuleAssetLoader', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('concat 자가등록 (IIFE 병합 계약)', () => {
|
describe('concat 자가등록 (IIFE 병합 계약)', () => {
|
||||||
|
/**
|
||||||
|
* @effects concat_iife_self_registration_preserved
|
||||||
|
*/
|
||||||
it(';\\n 로 이어붙인 2개 IIFE 가 모두 실행되어 레지스트리에 자가등록된다', () => {
|
it(';\\n 로 이어붙인 2개 IIFE 가 모두 실행되어 레지스트리에 자가등록된다', () => {
|
||||||
// 모의 IIFE 2개 — ecommerce IIFE 처럼 세미콜론 없이 끝나도 `\n;\n` 구분자로 ASI 경계 보호
|
// 모의 IIFE 2개 — ecommerce IIFE 처럼 세미콜론 없이 끝나도 `\n;\n` 구분자로 ASI 경계 보호
|
||||||
const registry: Record<string, unknown> = {};
|
const registry: Record<string, unknown> = {};
|
||||||
@@ -478,6 +491,83 @@ describe('ModuleAssetLoader', () => {
|
|||||||
loader.loadBundle('module', null, '/api/modules/bundle.css')
|
loader.loadBundle('module', null, '/api/modules/bundle.css')
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배너 항목명은 사용자 어휘여야 한다 — 내부 구분 키(module/plugin)를 그대로
|
||||||
|
* 넘기면 "module을(를) 불러오지 못했습니다" 처럼 해석 불가한 문구가 노출된다.
|
||||||
|
*
|
||||||
|
* 테스트 환경에는 G7Core.t 가 없으므로 폴백 문구가 쓰인다.
|
||||||
|
*
|
||||||
|
* @effects bundle_css_failure_banner_uses_user_vocabulary
|
||||||
|
* @since engine-v1.64.7
|
||||||
|
*/
|
||||||
|
it('번들 CSS 실패 안내 항목명이 사용자 어휘다 (모듈)', async () => {
|
||||||
|
clearAllAssetFailures();
|
||||||
|
stubAssetLoading(['error', 'error', 'error']);
|
||||||
|
|
||||||
|
await loader.loadBundle('module', null, '/api/modules/bundle.css');
|
||||||
|
|
||||||
|
const failure = getAssetFailures().find(f => f.id === 'ext-css:bundle-module');
|
||||||
|
|
||||||
|
expect(failure).toBeDefined();
|
||||||
|
expect(failure!.label).toBe('모듈 스타일');
|
||||||
|
expect(failure!.label).not.toBe('module');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @effects bundle_css_failure_banner_uses_user_vocabulary
|
||||||
|
* @since engine-v1.64.7
|
||||||
|
*/
|
||||||
|
it('번들 CSS 실패 안내 항목명이 사용자 어휘다 (플러그인)', async () => {
|
||||||
|
clearAllAssetFailures();
|
||||||
|
stubAssetLoading(['error', 'error', 'error']);
|
||||||
|
|
||||||
|
await loader.loadBundle('plugin', null, '/api/plugins/bundle.css');
|
||||||
|
|
||||||
|
const failure = getAssetFailures().find(f => f.id === 'ext-css:bundle-plugin');
|
||||||
|
|
||||||
|
expect(failure).toBeDefined();
|
||||||
|
expect(failure!.label).toBe('플러그인 스타일');
|
||||||
|
expect(failure!.label).not.toBe('plugin');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bundleUrls 부재(구버전 blade) 시 개별 로딩으로 폴백한다.
|
||||||
|
*
|
||||||
|
* 폴백 판정은 파싱 결과가 null 인지로 이뤄진다(TemplateApp.loadExtensionAssets).
|
||||||
|
* null 이 아닌데 폴백하거나 그 반대면 확장 에셋이 통째로 로드되지 않는데, 오류도
|
||||||
|
* 경고도 남지 않는다.
|
||||||
|
*
|
||||||
|
* @effects missing_bundle_urls_falls_back_to_individual_loading
|
||||||
|
*/
|
||||||
|
describe('bundleUrls 부재 폴백 판정', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete (window as any).G7Config;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('G7Config 자체가 없으면 null 이다 (개별 로딩 폴백)', () => {
|
||||||
|
delete (window as any).G7Config;
|
||||||
|
|
||||||
|
expect(parseBundleUrlsFromConfig()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('G7Config 는 있으나 bundleUrls 가 없으면 null 이다 (개별 로딩 폴백)', () => {
|
||||||
|
(window as any).G7Config = { moduleAssets: [] };
|
||||||
|
|
||||||
|
expect(parseBundleUrlsFromConfig()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bundleUrls 가 있으면 폴백하지 않는다 (번들 경로)', () => {
|
||||||
|
(window as any).G7Config = {
|
||||||
|
bundleUrls: { moduleJs: '/api/modules/bundle.js', moduleCss: null, pluginJs: null, pluginCss: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const urls = parseBundleUrlsFromConfig();
|
||||||
|
|
||||||
|
expect(urls).not.toBeNull();
|
||||||
|
expect(urls!.moduleJs).toContain('/api/modules/bundle');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('loadCSS in-flight Promise 공유', () => {
|
describe('loadCSS in-flight Promise 공유', () => {
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
>
|
>
|
||||||
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||||
|
|
||||||
|
## [engine-v1.64.7] - 2026-09-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
#### 확장 번들 스타일 실패 안내에 내부 구분 이름이 노출되던 문제
|
||||||
|
|
||||||
|
- 병합 CSS 번들 로드에 끝내 실패했을 때 안내 배너의 항목명이 번들 구분 키(`module`/`plugin`)였다. 사용자 어휘(`core.assets.module_styles` · `core.assets.plugin_styles`, 번역 미적재 시 폴백 문구)로 바꾼다 (ModuleAssetLoader.ts, AssetFailureNotice.ts)
|
||||||
|
|
||||||
## [engine-v1.64.0] - 2026-09-02
|
## [engine-v1.64.0] - 2026-09-02
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ SSoT 는 코어 `config/template.php` 의 `required_admin_components` 입니다.
|
|||||||
|
|
||||||
- [ ] `_bundled` 에서만 수정하고 `php artisan template:update gnuboard7-hello_admin_template --force` 로 반영
|
- [ ] `_bundled` 에서만 수정하고 `php artisan template:update gnuboard7-hello_admin_template --force` 로 반영
|
||||||
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` 동기화 + CHANGELOG 기재
|
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` 동기화 + CHANGELOG 기재
|
||||||
|
- [ ] `template.json` 의 `assets` 경로는 실제 산출물을 가리켜야 한다 — 없는 경로를 선언하면 검색엔진용(봇) 화면이 404 를 가리키는 `<link>` 를 싣고, 일반 화면에는 흔적이 남지 않는다
|
||||||
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
|
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
|
||||||
- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
|
- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
|
||||||
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
|
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
|
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
|
||||||
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
|
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 설정 파일의 스크립트 경로가 실제 빌드 산출물과 달랐던 것을 정정했습니다.
|
||||||
|
|
||||||
## [0.1.0] - 2026-07-01
|
## [0.1.0] - 2026-07-01
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
},
|
},
|
||||||
"assets": {
|
"assets": {
|
||||||
"js": [
|
"js": [
|
||||||
"dist/components.js"
|
"dist/js/components.iife.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ User 템플릿은 자기가 그리고 싶은 화면에 필요한 API 를 `data_s
|
|||||||
|
|
||||||
- [ ] `_bundled` 에서만 수정하고 `php artisan template:update gnuboard7-hello_user_template --force` 로 반영
|
- [ ] `_bundled` 에서만 수정하고 `php artisan template:update gnuboard7-hello_user_template --force` 로 반영
|
||||||
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` 동기화 + CHANGELOG 기재
|
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` 동기화 + CHANGELOG 기재
|
||||||
|
- [ ] `template.json` 의 `assets` 경로는 실제 산출물을 가리켜야 한다 — 없는 경로를 선언하면 검색엔진용(봇) 화면이 404 를 가리키는 `<link>` 를 싣고, 일반 화면에는 흔적이 남지 않는다
|
||||||
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
|
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
|
||||||
- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
|
- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
|
||||||
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
|
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
|
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
|
||||||
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
|
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 설정 파일이 존재하지 않는 스타일 파일을 선언해 검색엔진용 화면이 없는 주소를 참조하던 문제를 수정했습니다.
|
||||||
|
|
||||||
## [0.1.0] - 2026-07-01
|
## [0.1.0] - 2026-07-01
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -31,9 +31,7 @@
|
|||||||
"plugins": {}
|
"plugins": {}
|
||||||
},
|
},
|
||||||
"assets": {
|
"assets": {
|
||||||
"css": [
|
"css": [],
|
||||||
"dist/css/components.css"
|
|
||||||
],
|
|
||||||
"js": [
|
"js": [
|
||||||
"dist/js/components.iife.js"
|
"dist/js/components.iife.js"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
|
|||||||
|
|
||||||
- [ ] `_bundled` 에서만 수정하고 `php artisan template:update sirsoft-admin_basic --force` 로 반영
|
- [ ] `_bundled` 에서만 수정하고 `php artisan template:update sirsoft-admin_basic --force` 로 반영
|
||||||
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` 동기화 + CHANGELOG 기재
|
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` 동기화 + CHANGELOG 기재
|
||||||
|
- [ ] `template.json` 의 `assets` 경로는 실제 산출물을 가리켜야 한다 — 없는 경로를 선언하면 검색엔진용(봇) 화면이 404 를 가리키는 `<link>` 를 싣고, 일반 화면에는 흔적이 남지 않는다
|
||||||
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
|
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
|
||||||
- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
|
- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
|
||||||
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
|
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- 설정 파일의 자산 항목이 실제 파일 위치와 달랐던 것을 정정했습니다. 존재하지 않는 글꼴·이미지 폴더 항목은 제거했습니다.
|
||||||
- 환경설정 > 일반의 「자산 URL 방식 자동 감지」가 감지 결과를 선택 항목과 저장 내용에 반영합니다. 종전에는 버튼을 눌러도 선택이 그대로였고 진행·결과 안내도 표시되지 않았습니다.
|
- 환경설정 > 일반의 「자산 URL 방식 자동 감지」가 감지 결과를 선택 항목과 저장 내용에 반영합니다. 종전에는 버튼을 눌러도 선택이 그대로였고 진행·결과 안내도 표시되지 않았습니다.
|
||||||
- 대시보드 알림 중 일부 경고가 일반 안내와 같은 회색으로 표시되어 경고로 보이지 않던 문제를 수정했습니다. 이제 알림의 심각도에 따라 색이 정해집니다.
|
- 대시보드 알림 중 일부 경고가 일반 안내와 같은 회색으로 표시되어 경고로 보이지 않던 문제를 수정했습니다. 이제 알림의 심각도에 따라 색이 정해집니다.
|
||||||
- 경고 등급 알림을 대시보드 맨 아래 「시스템 알림」 칸이 아니라 **화면 상단**에 표시합니다. 종전에는 스크롤을 끝까지 내려야 보여, 화면이 정상으로 보이는 상황에서는 조치가 필요하다는 사실을 지나치기 쉬웠습니다. 경고가 아닌 안내는 종전대로 하단에 남으며, 같은 알림이 두 곳에 중복으로 뜨지 않습니다.
|
- 경고 등급 알림을 대시보드 맨 아래 「시스템 알림」 칸이 아니라 **화면 상단**에 표시합니다. 종전에는 스크롤을 끝까지 내려야 보여, 화면이 정상으로 보이는 상황에서는 조치가 필요하다는 사실을 지나치기 쉬웠습니다. 경고가 아닌 안내는 종전대로 하단에 남으며, 같은 알림이 두 곳에 중복으로 뜨지 않습니다.
|
||||||
|
|||||||
@@ -29,18 +29,13 @@
|
|||||||
},
|
},
|
||||||
"assets": {
|
"assets": {
|
||||||
"css": [
|
"css": [
|
||||||
"assets/css/main.css",
|
"dist/css/components.css"
|
||||||
"assets/css/ui-system.css"
|
|
||||||
],
|
],
|
||||||
"js": [
|
"js": [
|
||||||
"dist/components.js"
|
"dist/js/components.iife.js"
|
||||||
],
|
],
|
||||||
"fonts": [
|
"fonts": [],
|
||||||
"assets/fonts/"
|
"images": []
|
||||||
],
|
|
||||||
"images": [
|
|
||||||
"assets/images/"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"basic": [
|
"basic": [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Tests\Feature\Api\Public;
|
|||||||
|
|
||||||
use App\Services\ExtensionBundleService;
|
use App\Services\ExtensionBundleService;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Mockery;
|
use Mockery;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -40,24 +41,37 @@ class ExtensionBundleServingTest extends TestCase
|
|||||||
/**
|
/**
|
||||||
* 번들 서비스를 모킹합니다.
|
* 번들 서비스를 모킹합니다.
|
||||||
*
|
*
|
||||||
* `$memoryContent` / `$declaredCount` 는 빈 경로(`$returnPath === ''`)일 때의 분기를
|
* `$memoryContent` / `$declaredCount` / `$missing` 은 빈 경로(`$returnPath === ''`)일 때의
|
||||||
* 정한다 — 디스크 캐시 실패(메모리 폴백)인지, 선언 0개(정상 빈 번들)인지,
|
* 분기를 정한다 — 디스크 캐시 실패(메모리 폴백)인지, 병합 결과가 정당하게 비었는지
|
||||||
* 선언은 있는데 결과가 0인지(장애)를 컨트롤러가 이 둘로 가른다.
|
* (선언 0 이거나 선언한 산출물이 전부 존재하되 비어 있음), 선언한 산출물이 소실됐는지
|
||||||
|
* (장애)를 컨트롤러가 이 셋으로 가른다.
|
||||||
|
*
|
||||||
|
* 503 의 근거는 `$declaredCount` 가 아니라 `$missing` 이다. 선언만으로 판정하면
|
||||||
|
* "스타일이 비어 있는 확장" 의 정당한 상태가 배포 장애와 구분되지 않는다.
|
||||||
*
|
*
|
||||||
* @param string $returnPath getBundleFilePath 반환값
|
* @param string $returnPath getBundleFilePath 반환값
|
||||||
* @param string $memoryContent buildBundleContent 반환값
|
* @param string $memoryContent buildBundleContent 반환값
|
||||||
* @param int $declaredCount countAssetDeclaringExtensions 반환값
|
* @param int $declaredCount countAssetDeclaringExtensions 반환값
|
||||||
|
* @param list<string> $missing findMissingDeclaredAssets 반환값 (소실 산출물 절대 경로)
|
||||||
*/
|
*/
|
||||||
private function bindBundleService(string $returnPath, string $memoryContent = '', int $declaredCount = 0): void
|
private function bindBundleService(
|
||||||
{
|
string $returnPath,
|
||||||
|
string $memoryContent = '',
|
||||||
|
int $declaredCount = 0,
|
||||||
|
array $missing = []
|
||||||
|
): void {
|
||||||
$mock = Mockery::mock(ExtensionBundleService::class);
|
$mock = Mockery::mock(ExtensionBundleService::class);
|
||||||
$mock->shouldReceive('getCurrentVersion')->andReturn(777);
|
$mock->shouldReceive('getCurrentVersion')->andReturn(777);
|
||||||
$mock->shouldReceive('getBundleFilePath')->andReturn($returnPath);
|
$mock->shouldReceive('getBundleFilePath')->andReturn($returnPath);
|
||||||
$mock->shouldReceive('buildBundleContent')->andReturn($memoryContent);
|
$mock->shouldReceive('buildBundleContent')->andReturn($memoryContent);
|
||||||
$mock->shouldReceive('countAssetDeclaringExtensions')->andReturn($declaredCount);
|
$mock->shouldReceive('countAssetDeclaringExtensions')->andReturn($declaredCount);
|
||||||
|
$mock->shouldReceive('findMissingDeclaredAssets')->andReturn($missing);
|
||||||
$this->app->instance(ExtensionBundleService::class, $mock);
|
$this->app->instance(ExtensionBundleService::class, $mock);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @effects bundle_endpoint_serves_with_etag_and_304
|
||||||
|
*/
|
||||||
public function test_module_bundle_js_serves_merged_file_with_etag(): void
|
public function test_module_bundle_js_serves_merged_file_with_etag(): void
|
||||||
{
|
{
|
||||||
$path = $this->fixtureDir.'/module.777.js';
|
$path = $this->fixtureDir.'/module.777.js';
|
||||||
@@ -73,6 +87,9 @@ class ExtensionBundleServingTest extends TestCase
|
|||||||
$this->assertStringContainsString("\n;\n", $response->streamedContent() ?? $response->getContent());
|
$this->assertStringContainsString("\n;\n", $response->streamedContent() ?? $response->getContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @effects bundle_endpoint_serves_with_etag_and_304
|
||||||
|
*/
|
||||||
public function test_bundle_returns_304_on_matching_etag(): void
|
public function test_bundle_returns_304_on_matching_etag(): void
|
||||||
{
|
{
|
||||||
$path = $this->fixtureDir.'/plugin.777.js';
|
$path = $this->fixtureDir.'/plugin.777.js';
|
||||||
@@ -88,6 +105,9 @@ class ExtensionBundleServingTest extends TestCase
|
|||||||
$second->assertStatus(304);
|
$second->assertStatus(304);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @effects empty_bundle_returns_empty_path_and_empty_ok_response
|
||||||
|
*/
|
||||||
public function test_empty_bundle_returns_empty_ok_response(): void
|
public function test_empty_bundle_returns_empty_ok_response(): void
|
||||||
{
|
{
|
||||||
// 에셋을 선언한 활성 확장이 0개 → 빈 번들이 정상이다 → 빈 200
|
// 에셋을 선언한 활성 확장이 0개 → 빈 번들이 정상이다 → 빈 200
|
||||||
@@ -125,23 +145,90 @@ class ExtensionBundleServingTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 선언은 있는데 병합 결과가 비면 503 이다 (E2).
|
* 선언한 산출물이 소실됐는데 병합 결과가 비면 503 이다 (E2).
|
||||||
*
|
*
|
||||||
* 배포 중 `dist` 가 잠깐 비면 **모든 확장이 빠진 빈 번들**이 200 으로 나가고, 프론트는
|
* 배포 중 `dist` 가 잠깐 비면 **모든 확장이 빠진 빈 번들**이 200 으로 나가고, 프론트는
|
||||||
* 404 도 오류도 받지 못한 채 한참 뒤 "Unknown action handler" 로 죽는다 — 그 시점에는
|
* 404 도 오류도 받지 못한 채 한참 뒤 "Unknown action handler" 로 죽는다 — 그 시점에는
|
||||||
* 원인이 번들이라는 사실이 화면에도 로그에도 남아 있지 않다.
|
* 원인이 번들이라는 사실이 화면에도 로그에도 남아 있지 않다.
|
||||||
*
|
*
|
||||||
* @effects bundle_empty_with_declared_assets_returns_503
|
* 판정 근거는 선언 축이 아니라 **소실 축**이다. 선언 축만 보면 스타일이 비어 있는
|
||||||
|
* 확장(정당한 0바이트 산출물)까지 장애로 잡혀 기본 구성이 503 이 된다.
|
||||||
|
*
|
||||||
|
* // @scenario ext_type=module, asset_kind=js, active_combo=multiple, file_state=one_missing
|
||||||
|
*
|
||||||
|
* @effects bundle_empty_with_declared_assets_returns_503, empty_result_with_missing_declared_artifact_returns_503
|
||||||
*/
|
*/
|
||||||
public function test_empty_bundle_with_declared_assets_returns_503(): void
|
public function test_empty_bundle_with_declared_assets_returns_503(): void
|
||||||
{
|
{
|
||||||
$this->bindBundleService('', memoryContent: '', declaredCount: 3);
|
$this->bindBundleService('', memoryContent: '', declaredCount: 3, missing: ['/x/dist/js/a.js']);
|
||||||
|
|
||||||
$response = $this->get('/api/modules/bundle.js?v=777');
|
$response = $this->get('/api/modules/bundle.js?v=777');
|
||||||
|
|
||||||
$response->assertStatus(503);
|
$response->assertStatus(503);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선언한 산출물이 **존재하되 비어 있으면** 정상 빈 200 이다 (이 이슈의 재현).
|
||||||
|
*
|
||||||
|
* 스타일 소스가 자리표시 주석뿐이라 0바이트 CSS 를 내보내는 확장은 정당한 상태다.
|
||||||
|
* 종전 판정은 "선언 > 0 && 병합 결과 0" 을 곧 배포 장애로 등치해, 그런 확장만 설치된
|
||||||
|
* 기본 구성의 사용자 화면마다 실패 안내가 떴다.
|
||||||
|
*
|
||||||
|
* // @scenario ext_type=module, asset_kind=css, active_combo=one, file_state=present_empty
|
||||||
|
*
|
||||||
|
* @effects empty_result_with_present_empty_artifacts_returns_200
|
||||||
|
*/
|
||||||
|
public function test_empty_result_with_present_empty_artifacts_returns_200(): void
|
||||||
|
{
|
||||||
|
$this->bindBundleService('', memoryContent: '', declaredCount: 1, missing: []);
|
||||||
|
|
||||||
|
$response = $this->get('/api/modules/bundle.css?v=777');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertStringStartsWith('text/css', $response->headers->get('Content-Type'));
|
||||||
|
$this->assertSame('', $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플러그인 경로도 같은 판정을 쓴다 — 존재하되 비어 있으면 빈 200.
|
||||||
|
*
|
||||||
|
* // @scenario ext_type=plugin, asset_kind=css, active_combo=one, file_state=present_empty
|
||||||
|
*
|
||||||
|
* @effects bundle_decision_is_shared_across_extension_types, empty_result_with_present_empty_artifacts_returns_200
|
||||||
|
*/
|
||||||
|
public function test_plugin_bundle_returns_200_when_declared_artifacts_present_but_empty(): void
|
||||||
|
{
|
||||||
|
$this->bindBundleService('', memoryContent: '', declaredCount: 1, missing: []);
|
||||||
|
|
||||||
|
$response = $this->get('/api/plugins/bundle.css?v=777');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertSame('', $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 503 로그가 **어느 산출물이 없는지**를 싣는다.
|
||||||
|
*
|
||||||
|
* 경로가 없으면 운영자는 조치 대상을 짚을 수 없다 — 선언 수만으로는 어느 확장의
|
||||||
|
* 어느 파일인지 알 수 없다.
|
||||||
|
*
|
||||||
|
* // @scenario ext_type=module, asset_kind=css, active_combo=one, file_state=one_missing
|
||||||
|
*
|
||||||
|
* @effects missing_artifact_paths_are_logged
|
||||||
|
*/
|
||||||
|
public function test_503_log_lists_missing_artifact_paths(): void
|
||||||
|
{
|
||||||
|
Log::spy();
|
||||||
|
|
||||||
|
$this->bindBundleService('', memoryContent: '', declaredCount: 1, missing: ['/x/dist/css/a.css']);
|
||||||
|
|
||||||
|
$this->get('/api/modules/bundle.css?v=777')->assertStatus(503);
|
||||||
|
|
||||||
|
Log::shouldHaveReceived('error')->withArgs(
|
||||||
|
fn ($message, $context) => ($context['missing'] ?? null) === ['/x/dist/css/a.css']
|
||||||
|
)->once();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 플러그인 경로도 같은 판정을 쓴다 — 두 컨트롤러가 갈라지지 않는다 (장애 쪽).
|
* 플러그인 경로도 같은 판정을 쓴다 — 두 컨트롤러가 갈라지지 않는다 (장애 쪽).
|
||||||
*
|
*
|
||||||
@@ -149,7 +236,7 @@ class ExtensionBundleServingTest extends TestCase
|
|||||||
*/
|
*/
|
||||||
public function test_plugin_bundle_returns_503_when_declared_but_empty(): void
|
public function test_plugin_bundle_returns_503_when_declared_but_empty(): void
|
||||||
{
|
{
|
||||||
$this->bindBundleService('', memoryContent: '', declaredCount: 1);
|
$this->bindBundleService('', memoryContent: '', declaredCount: 1, missing: ['/x/dist/css/a.css']);
|
||||||
|
|
||||||
$this->get('/api/plugins/bundle.css?v=777')->assertStatus(503);
|
$this->get('/api/plugins/bundle.css?v=777')->assertStatus(503);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* E2E: 확장 프론트엔드 병합 번들 로딩 회귀 검증
|
* E2E: 확장 프론트엔드 병합 번들 로딩 회귀 검증
|
||||||
*
|
*
|
||||||
* @scenario extension-bundle-loading
|
* @scenario extension-bundle-loading
|
||||||
* @effects bundle_js_requested_once_per_type, individual_iife_requests_absent,
|
* @effects individual_iife_requests_absent_in_browser, bundle_url_same_origin_only,
|
||||||
* gdpr_interceptor_still_active, extension_handlers_registered
|
* gdpr_interceptor_still_active_after_merge, extension_handlers_registered_no_console_error
|
||||||
*
|
*
|
||||||
* 배경: 활성 모듈/플러그인 IIFE 를 서버측에서 종류별 1개 번들로 병합 서빙한다.
|
* 배경: 활성 모듈/플러그인 IIFE 를 서버측에서 종류별 1개 번들로 병합 서빙한다.
|
||||||
* 실제 페이지 로드 시 (1) 개별 `*.iife.js` 요청이 사라지고 `bundle.js` 로 대체되는지,
|
* 실제 페이지 로드 시 (1) 개별 `*.iife.js` 요청이 사라지고 `bundle.js` 로 대체되는지,
|
||||||
@@ -22,8 +22,8 @@ test.describe('확장 병합 번들 로딩', () => {
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||||
|
|
||||||
const moduleBundle = requestedUrls.filter((u) => /\/api\/modules\/bundle\.js/.test(u));
|
const moduleBundle = requestedUrls.filter((u) => /\/api\/modules\/bundle[./]js/.test(u));
|
||||||
const pluginBundle = requestedUrls.filter((u) => /\/api\/plugins\/bundle\.js/.test(u));
|
const pluginBundle = requestedUrls.filter((u) => /\/api\/plugins\/bundle[./]js/.test(u));
|
||||||
|
|
||||||
// 활성 확장이 있으면 번들이 요청되고, 종류별로 1건씩만 나가야 한다
|
// 활성 확장이 있으면 번들이 요청되고, 종류별로 1건씩만 나가야 한다
|
||||||
const individualIife = requestedUrls.filter((u) => /\/api\/(modules|plugins)\/assets\/.*\.iife\.js/.test(u));
|
const individualIife = requestedUrls.filter((u) => /\/api\/(modules|plugins)\/assets\/.*\.iife\.js/.test(u));
|
||||||
@@ -39,7 +39,7 @@ test.describe('확장 병합 번들 로딩', () => {
|
|||||||
test('@smoke 병합 번들 응답이 정상(200/304)이고 same-origin 이다', async ({ page }) => {
|
test('@smoke 병합 번들 응답이 정상(200/304)이고 same-origin 이다', async ({ page }) => {
|
||||||
const bundleResponses: { url: string; status: number }[] = [];
|
const bundleResponses: { url: string; status: number }[] = [];
|
||||||
page.on('response', (res) => {
|
page.on('response', (res) => {
|
||||||
if (/\/api\/(modules|plugins)\/bundle\.(js|css)/.test(res.url())) {
|
if (/\/api\/(modules|plugins)\/bundle[./](js|css)/.test(res.url())) {
|
||||||
bundleResponses.push({ url: res.url(), status: res.status() });
|
bundleResponses.push({ url: res.url(), status: res.status() });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -75,4 +75,51 @@ test.describe('확장 병합 번들 로딩', () => {
|
|||||||
);
|
);
|
||||||
expect(parseErrors, `번들 실행 관련 콘솔 에러: ${parseErrors.join(' | ')}`).toHaveLength(0);
|
expect(parseErrors, `번들 실행 관련 콘솔 에러: ${parseErrors.join(' | ')}`).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정상 구성에서는 자산 실패 안내가 뜨지 않는다.
|
||||||
|
*
|
||||||
|
* 스타일이 비어 있는 확장(0바이트 CSS)만 설치된 기본 구성에서 번들 CSS 가 503 이 되어
|
||||||
|
* 사용자 화면마다 안내 배너가 떴다. 서버가 그 상태를 정상 빈 200 으로 판정한 뒤로는
|
||||||
|
* 배너가 없어야 한다.
|
||||||
|
*
|
||||||
|
* // @scenario ext_type=module, asset_kind=css, active_combo=one, file_state=present_empty
|
||||||
|
*
|
||||||
|
* @effects empty_result_with_present_empty_artifacts_returns_200
|
||||||
|
*/
|
||||||
|
test('@smoke 정상 로드 시 자산 실패 안내가 없다', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||||
|
|
||||||
|
await expect(page.locator('#g7-asset-failure-notice')).toHaveCount(0);
|
||||||
|
|
||||||
|
const failures = await page.evaluate(
|
||||||
|
() => (window as any).G7Core?.assets?.getFailures?.() ?? [],
|
||||||
|
);
|
||||||
|
expect(failures, `자산 실패가 남아있음: ${JSON.stringify(failures)}`).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 진짜 소실(503)일 때의 안내 항목명은 사용자 어휘여야 한다.
|
||||||
|
*
|
||||||
|
* 종전에는 번들 구분 키가 그대로 나와 "module을(를) 불러오지 못했습니다" 로 보였다.
|
||||||
|
*
|
||||||
|
* // @scenario ext_type=module, asset_kind=css, active_combo=one, file_state=one_missing
|
||||||
|
*
|
||||||
|
* @effects bundle_css_failure_banner_uses_user_vocabulary
|
||||||
|
*/
|
||||||
|
test('번들 CSS 가 503 이면 안내 항목명이 사용자 어휘다', async ({ page }) => {
|
||||||
|
await page.route(/\/api\/(modules|plugins)\/bundle[./]css/, (route) =>
|
||||||
|
route.fulfill({ status: 503, contentType: 'text/css', body: '' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const notice = page.locator('#g7-asset-failure-notice');
|
||||||
|
await expect(notice).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const text = (await notice.innerText()).replace(/\s+/g, ' ');
|
||||||
|
expect(text, `배너 문구: ${text}`).toContain('모듈 스타일');
|
||||||
|
expect(text, `내부 구분 키가 노출됨: ${text}`).not.toContain('module을(를)');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Extension;
|
||||||
|
|
||||||
|
use App\Extension\AbstractModule;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AbstractModule::getDeclaredAssetAbsolutePaths() 단위 테스트
|
||||||
|
*
|
||||||
|
* 이 게터는 **선언 축**이다 — 매니페스트가 선언한 산출물의 절대 경로를 파일 존재와
|
||||||
|
* 무관하게 돌려준다. `getBuiltAssetAbsolutePaths()` 는 `file_exists()` 게이트라
|
||||||
|
* 소실된 산출물이 목록에서 사라져 "선언은 있는데 파일이 없다" 를 셀 수 없다.
|
||||||
|
*/
|
||||||
|
class AbstractModuleDeclaredAssetsTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $moduleDir;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->moduleDir = storage_path('framework/testing/declared-assets-module/vendor-sample');
|
||||||
|
File::ensureDirectoryExists($this->moduleDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
File::deleteDirectory(dirname($this->moduleDir));
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 임시 디렉토리를 모듈 루트로 쓰는 익명 서브클래스를 만든다.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $assets 매니페스트 assets 선언
|
||||||
|
*/
|
||||||
|
private function moduleWithAssets(array $assets): AbstractModule
|
||||||
|
{
|
||||||
|
File::put(
|
||||||
|
$this->moduleDir.'/module.json',
|
||||||
|
json_encode(['name' => 'sample', 'version' => '1.0.0', 'assets' => $assets], JSON_UNESCAPED_UNICODE)
|
||||||
|
);
|
||||||
|
|
||||||
|
$root = $this->moduleDir;
|
||||||
|
|
||||||
|
return new class($root) extends AbstractModule
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $root) {}
|
||||||
|
|
||||||
|
protected function getModulePath(): string
|
||||||
|
{
|
||||||
|
return $this->root;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 산출물이 하나도 없어도 선언한 kind 마다 절대 경로가 나온다.
|
||||||
|
*/
|
||||||
|
public function test_returns_declared_paths_even_when_files_are_absent(): void
|
||||||
|
{
|
||||||
|
$module = $this->moduleWithAssets([
|
||||||
|
'js' => ['output' => 'dist/js/module.iife.js'],
|
||||||
|
'css' => ['output' => 'dist/css/module.css'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$declared = $module->getDeclaredAssetAbsolutePaths();
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
[
|
||||||
|
'js' => $this->moduleDir.'/dist/js/module.iife.js',
|
||||||
|
'css' => $this->moduleDir.'/dist/css/module.css',
|
||||||
|
],
|
||||||
|
$declared
|
||||||
|
);
|
||||||
|
// 존재 게이트를 타는 게터는 같은 상태에서 비어 있다 — 두 축이 다르다
|
||||||
|
$this->assertSame([], $module->getBuiltAssetAbsolutePaths());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선언하지 않은 kind 는 키 자체가 없다.
|
||||||
|
*/
|
||||||
|
public function test_omits_undeclared_kinds(): void
|
||||||
|
{
|
||||||
|
$module = $this->moduleWithAssets(['js' => ['output' => 'dist/js/module.iife.js']]);
|
||||||
|
|
||||||
|
$declared = $module->getDeclaredAssetAbsolutePaths();
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('js', $declared);
|
||||||
|
$this->assertArrayNotHasKey('css', $declared);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `output` 이 비어 있으면 선언으로 보지 않는다 (빈 문자열·빈 배열).
|
||||||
|
*/
|
||||||
|
public function test_treats_empty_output_as_undeclared(): void
|
||||||
|
{
|
||||||
|
$module = $this->moduleWithAssets([
|
||||||
|
'js' => ['output' => ''],
|
||||||
|
'css' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame([], $module->getDeclaredAssetAbsolutePaths());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 산출물이 존재하되 0바이트여도 경로는 그대로 나온다 — 존재 판정은 호출자 몫이다.
|
||||||
|
*/
|
||||||
|
public function test_returns_path_for_present_zero_byte_artifact(): void
|
||||||
|
{
|
||||||
|
$module = $this->moduleWithAssets(['css' => ['output' => 'dist/css/module.css']]);
|
||||||
|
File::ensureDirectoryExists($this->moduleDir.'/dist/css');
|
||||||
|
File::put($this->moduleDir.'/dist/css/module.css', '');
|
||||||
|
|
||||||
|
$path = $module->getDeclaredAssetAbsolutePaths()['css'];
|
||||||
|
|
||||||
|
$this->assertSame($this->moduleDir.'/dist/css/module.css', $path);
|
||||||
|
$this->assertTrue(is_file($path));
|
||||||
|
$this->assertSame(0, filesize($path));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Extension;
|
||||||
|
|
||||||
|
use App\Extension\AbstractPlugin;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AbstractPlugin::getDeclaredAssetAbsolutePaths() 단위 테스트
|
||||||
|
*
|
||||||
|
* 모듈 축과 동형이다 — 한쪽만 고쳐지면 플러그인 번들의 소실 판정이 조용히 침묵한다.
|
||||||
|
*
|
||||||
|
* @see AbstractModuleDeclaredAssetsTest
|
||||||
|
*/
|
||||||
|
class AbstractPluginDeclaredAssetsTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $pluginDir;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->pluginDir = storage_path('framework/testing/declared-assets-plugin/vendor-sample');
|
||||||
|
File::ensureDirectoryExists($this->pluginDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
File::deleteDirectory(dirname($this->pluginDir));
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 임시 디렉토리를 플러그인 루트로 쓰는 익명 서브클래스를 만든다.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $assets 매니페스트 assets 선언
|
||||||
|
*/
|
||||||
|
private function pluginWithAssets(array $assets): AbstractPlugin
|
||||||
|
{
|
||||||
|
File::put(
|
||||||
|
$this->pluginDir.'/plugin.json',
|
||||||
|
json_encode(['name' => 'sample', 'version' => '1.0.0', 'assets' => $assets], JSON_UNESCAPED_UNICODE)
|
||||||
|
);
|
||||||
|
|
||||||
|
$root = $this->pluginDir;
|
||||||
|
|
||||||
|
return new class($root) extends AbstractPlugin
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $root) {}
|
||||||
|
|
||||||
|
protected function getPluginPath(): string
|
||||||
|
{
|
||||||
|
return $this->root;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 산출물이 하나도 없어도 선언한 kind 마다 절대 경로가 나온다.
|
||||||
|
*/
|
||||||
|
public function test_returns_declared_paths_even_when_files_are_absent(): void
|
||||||
|
{
|
||||||
|
$plugin = $this->pluginWithAssets([
|
||||||
|
'js' => ['output' => 'dist/js/plugin.iife.js'],
|
||||||
|
'css' => ['output' => 'dist/css/plugin.css'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
[
|
||||||
|
'js' => $this->pluginDir.'/dist/js/plugin.iife.js',
|
||||||
|
'css' => $this->pluginDir.'/dist/css/plugin.css',
|
||||||
|
],
|
||||||
|
$plugin->getDeclaredAssetAbsolutePaths()
|
||||||
|
);
|
||||||
|
$this->assertSame([], $plugin->getBuiltAssetAbsolutePaths());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선언하지 않은 kind 는 키 자체가 없다.
|
||||||
|
*/
|
||||||
|
public function test_omits_undeclared_kinds(): void
|
||||||
|
{
|
||||||
|
$plugin = $this->pluginWithAssets(['css' => ['output' => 'dist/css/plugin.css']]);
|
||||||
|
|
||||||
|
$declared = $plugin->getDeclaredAssetAbsolutePaths();
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('css', $declared);
|
||||||
|
$this->assertArrayNotHasKey('js', $declared);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `output` 이 비어 있으면 선언으로 보지 않는다.
|
||||||
|
*/
|
||||||
|
public function test_treats_empty_output_as_undeclared(): void
|
||||||
|
{
|
||||||
|
$plugin = $this->pluginWithAssets([
|
||||||
|
'js' => ['output' => ''],
|
||||||
|
'css' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame([], $plugin->getDeclaredAssetAbsolutePaths());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 산출물이 존재하되 0바이트여도 경로는 그대로 나온다.
|
||||||
|
*/
|
||||||
|
public function test_returns_path_for_present_zero_byte_artifact(): void
|
||||||
|
{
|
||||||
|
$plugin = $this->pluginWithAssets(['css' => ['output' => 'dist/css/plugin.css']]);
|
||||||
|
File::ensureDirectoryExists($this->pluginDir.'/dist/css');
|
||||||
|
File::put($this->pluginDir.'/dist/css/plugin.css', '');
|
||||||
|
|
||||||
|
$path = $plugin->getDeclaredAssetAbsolutePaths()['css'];
|
||||||
|
|
||||||
|
$this->assertSame($this->pluginDir.'/dist/css/plugin.css', $path);
|
||||||
|
$this->assertTrue(is_file($path));
|
||||||
|
$this->assertSame(0, filesize($path));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Extension;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 번들 확장 매니페스트의 자산 선언 계약 테스트
|
||||||
|
*
|
||||||
|
* 매니페스트가 선언한 산출물 경로는 **디스크에 실재해야 한다**. 어긋나면 증상이
|
||||||
|
* 종류마다 다르고 전부 조용하다:
|
||||||
|
*
|
||||||
|
* - 모듈/플러그인의 `assets.{js,css}` 가 배열형이면 어느 소비자도 읽지 못해
|
||||||
|
* 그 확장의 스크립트·스타일이 영영 로드되지 않는다 (오류 없음).
|
||||||
|
* - 템플릿의 `assets.css` 가 없는 파일을 가리키면 봇 화면(SeoRenderer)이 404 를
|
||||||
|
* 가리키는 `<link>` 를 싣는다 — 일반 화면에는 흔적이 없다.
|
||||||
|
*
|
||||||
|
* 0바이트 산출물은 **정당한 상태**다(스타일이 비어 있는 확장). 이 테스트는 존재만
|
||||||
|
* 본다 — 크기를 보면 그 확장이 다시 배포 장애로 오판된다.
|
||||||
|
*
|
||||||
|
* 모집단은 glob 으로 파생한다. 손으로 적은 목록은 확장이 늘어날 때 조용히 낡는다.
|
||||||
|
*/
|
||||||
|
class BundledManifestAssetDeclarationTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 모듈·플러그인 매니페스트의 `assets.{js,css}` 는 객체이며 산출물이 실재해야 한다.
|
||||||
|
*/
|
||||||
|
public function test_module_and_plugin_asset_declarations_are_objects_pointing_to_existing_files(): void
|
||||||
|
{
|
||||||
|
$manifests = [];
|
||||||
|
|
||||||
|
foreach ([['module', 'modules/_bundled'], ['plugin', 'plugins/_bundled']] as [$type, $root]) {
|
||||||
|
foreach (glob(base_path($root).'/*/'.$type.'.json') as $manifestPath) {
|
||||||
|
$manifests[] = [$manifestPath, dirname($manifestPath)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모집단 하한 가드 — glob 이 0건이면 이 테스트는 아무것도 잠그지 않는다
|
||||||
|
$this->assertGreaterThanOrEqual(
|
||||||
|
10,
|
||||||
|
count($manifests),
|
||||||
|
'번들 모듈/플러그인 매니페스트 모집단이 비었거나 지나치게 작다 — 경로 규약이 바뀐 것이 아닌지 확인하라'
|
||||||
|
);
|
||||||
|
|
||||||
|
$violations = [];
|
||||||
|
|
||||||
|
foreach ($manifests as [$manifestPath, $extensionDir]) {
|
||||||
|
$manifest = json_decode((string) file_get_contents($manifestPath), true) ?? [];
|
||||||
|
$assets = $manifest['assets'] ?? null;
|
||||||
|
|
||||||
|
if ($assets === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertIsArray($assets, $manifestPath.': assets 는 객체여야 한다');
|
||||||
|
|
||||||
|
foreach (['js', 'css'] as $kind) {
|
||||||
|
if (! array_key_exists($kind, $assets)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$declaration = $assets[$kind];
|
||||||
|
$relative = base_path().DIRECTORY_SEPARATOR;
|
||||||
|
$shortManifest = str_replace($relative, '', $manifestPath);
|
||||||
|
|
||||||
|
if (! is_array($declaration) || ! isset($declaration['output']) || ! is_string($declaration['output']) || $declaration['output'] === '') {
|
||||||
|
$violations[] = sprintf(
|
||||||
|
'%s: assets.%s 는 { "output": "..." } 객체여야 한다 (현재: %s)',
|
||||||
|
$shortManifest,
|
||||||
|
$kind,
|
||||||
|
json_encode($declaration, JSON_UNESCAPED_UNICODE)
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$artifact = $extensionDir.'/'.$declaration['output'];
|
||||||
|
|
||||||
|
if (! is_file($artifact)) {
|
||||||
|
$violations[] = sprintf(
|
||||||
|
'%s: assets.%s.output 이 가리키는 산출물이 없다 — %s',
|
||||||
|
$shortManifest,
|
||||||
|
$kind,
|
||||||
|
$declaration['output']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame([], $violations, "번들 확장 매니페스트 자산 선언 위반:\n".implode("\n", $violations));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 템플릿 매니페스트의 `assets` 경로도 실재해야 한다.
|
||||||
|
*
|
||||||
|
* `css`/`js` 는 파일, `fonts`/`images` 는 디렉토리로 본다. 빈 배열은 "선언 없음" 이라
|
||||||
|
* 정상이다.
|
||||||
|
*/
|
||||||
|
public function test_template_asset_declarations_point_to_existing_files(): void
|
||||||
|
{
|
||||||
|
$manifests = glob(base_path('templates/_bundled').'/*/template.json');
|
||||||
|
|
||||||
|
$this->assertGreaterThanOrEqual(
|
||||||
|
3,
|
||||||
|
count($manifests),
|
||||||
|
'번들 템플릿 매니페스트 모집단이 비었거나 지나치게 작다'
|
||||||
|
);
|
||||||
|
|
||||||
|
$violations = [];
|
||||||
|
$directoryKinds = ['fonts', 'images'];
|
||||||
|
|
||||||
|
foreach ($manifests as $manifestPath) {
|
||||||
|
$manifest = json_decode((string) file_get_contents($manifestPath), true) ?? [];
|
||||||
|
$templateDir = dirname($manifestPath);
|
||||||
|
$shortManifest = str_replace(base_path().DIRECTORY_SEPARATOR, '', $manifestPath);
|
||||||
|
|
||||||
|
foreach (($manifest['assets'] ?? []) as $kind => $paths) {
|
||||||
|
foreach ((array) $paths as $candidate) {
|
||||||
|
$absolute = $templateDir.'/'.$candidate;
|
||||||
|
$exists = in_array($kind, $directoryKinds, true)
|
||||||
|
? is_dir($absolute)
|
||||||
|
: is_file($absolute);
|
||||||
|
|
||||||
|
if (! $exists) {
|
||||||
|
$violations[] = sprintf('%s: assets.%s 의 %s 가 없다', $shortManifest, $kind, $candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame([], $violations, "번들 템플릿 매니페스트 자산 선언 위반:\n".implode("\n", $violations));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Seo;
|
||||||
|
|
||||||
|
use App\Seo\SeoRenderer;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use ReflectionMethod;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SeoRenderer 가 봇 화면에 싣는 템플릿 CSS 는 파일이 있을 때만 링크한다.
|
||||||
|
*
|
||||||
|
* `template.json` 의 `assets.css` 는 선언일 뿐이라 산출물이 없을 수 있다. 그 경로를
|
||||||
|
* 그대로 `<link>` 로 실으면 봇 화면에서만 404 가 나고 일반 화면에는 흔적이 없다 —
|
||||||
|
* 서버 로그에도 남지 않으므로 운영자가 알 방법이 없다.
|
||||||
|
*/
|
||||||
|
class SeoRendererTemplateCssGateTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $templateRoot;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->templateRoot = storage_path('framework/testing/seo-template-css/vendor-sample');
|
||||||
|
File::ensureDirectoryExists($this->templateRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
File::deleteDirectory(dirname($this->templateRoot));
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 임시 디렉토리를 템플릿 루트로 쓰는 익명 서브클래스를 만든다.
|
||||||
|
*
|
||||||
|
* @param list<string> $cssPaths template.json 의 assets.css 선언
|
||||||
|
*/
|
||||||
|
private function rendererWithTemplateCss(array $cssPaths): SeoRenderer
|
||||||
|
{
|
||||||
|
File::put(
|
||||||
|
$this->templateRoot.'/template.json',
|
||||||
|
json_encode(['name' => 'sample', 'assets' => ['css' => $cssPaths]], JSON_UNESCAPED_UNICODE)
|
||||||
|
);
|
||||||
|
|
||||||
|
$root = $this->templateRoot;
|
||||||
|
|
||||||
|
// 부모 생성자(의존 12개)는 호출하지 않는다 — getTemplateCssUrls 는 그 의존을
|
||||||
|
// 하나도 쓰지 않으므로 미초기화 프로퍼티에 닿지 않는다.
|
||||||
|
return new class($root) extends SeoRenderer
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $root) {}
|
||||||
|
|
||||||
|
protected function templateRootPath(string $identifier): string
|
||||||
|
{
|
||||||
|
return $this->root;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $cssPaths
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function cssUrls(array $cssPaths): array
|
||||||
|
{
|
||||||
|
$renderer = $this->rendererWithTemplateCss($cssPaths);
|
||||||
|
$method = new ReflectionMethod(SeoRenderer::class, 'getTemplateCssUrls');
|
||||||
|
|
||||||
|
return $method->invoke($renderer, 'vendor-sample');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 존재하는 CSS 만 URL 로 나온다 — 없는 경로는 조용히 빠진다.
|
||||||
|
*/
|
||||||
|
public function test_only_existing_css_paths_become_urls(): void
|
||||||
|
{
|
||||||
|
File::ensureDirectoryExists($this->templateRoot.'/dist/css');
|
||||||
|
File::put($this->templateRoot.'/dist/css/components.css', '.a{color:red}');
|
||||||
|
|
||||||
|
$urls = $this->cssUrls(['dist/css/components.css', 'dist/css/absent.css']);
|
||||||
|
|
||||||
|
$this->assertCount(1, $urls);
|
||||||
|
$this->assertStringContainsString('css/components.css', $urls[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선언한 CSS 가 하나도 없으면 빈 배열이다 (404 링크를 만들지 않는다).
|
||||||
|
*/
|
||||||
|
public function test_absent_css_declaration_yields_no_urls(): void
|
||||||
|
{
|
||||||
|
$this->assertSame([], $this->cssUrls(['dist/css/components.css']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 0바이트 CSS 는 존재하므로 링크한다 — 존재 여부만 본다.
|
||||||
|
*/
|
||||||
|
public function test_present_zero_byte_css_is_still_linked(): void
|
||||||
|
{
|
||||||
|
File::ensureDirectoryExists($this->templateRoot.'/dist/css');
|
||||||
|
File::put($this->templateRoot.'/dist/css/components.css', '');
|
||||||
|
|
||||||
|
$this->assertCount(1, $this->cssUrls(['dist/css/components.css']));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,16 +110,26 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
/**
|
/**
|
||||||
* 에셋 **매니페스트 선언**(`getAssets()`)을 노출하는 가짜 확장 인스턴스를 만든다.
|
* 에셋 **매니페스트 선언**(`getAssets()`)을 노출하는 가짜 확장 인스턴스를 만든다.
|
||||||
*
|
*
|
||||||
* 산출물 경로(`getBuiltAssetAbsolutePaths`)는 존재하지 않는 파일을 가리킨다 —
|
* 산출물 경로(`getBuiltAssetAbsolutePaths`)는 기본적으로 존재하지 않는 파일을 가리킨다 —
|
||||||
* "선언은 있는데 산출물이 없다"(dist 소실) 상태를 그대로 재현하기 위해서다.
|
* "선언은 있는데 산출물이 없다"(dist 소실) 상태를 그대로 재현하기 위해서다.
|
||||||
*
|
*
|
||||||
|
* 선언 축 게터(`getDeclaredAssetAbsolutePaths`)는 파일 존재와 무관하게 선언된 kind 마다
|
||||||
|
* 경로를 돌려준다. `$declaredPaths` 로 그 경로를 지정하면 실제로 만들어 둔 fixture 파일을
|
||||||
|
* 가리키게 해 "존재하되 비어 있음" 상태를 만들 수 있다.
|
||||||
|
*
|
||||||
* @param string $identifier 확장 식별자
|
* @param string $identifier 확장 식별자
|
||||||
* @param int $priority 로딩 우선순위
|
* @param int $priority 로딩 우선순위
|
||||||
* @param array<string, mixed> $assets 매니페스트 assets 선언
|
* @param array<string, mixed> $assets 매니페스트 assets 선언
|
||||||
* @param string $strategy 로딩 전략
|
* @param string $strategy 로딩 전략
|
||||||
|
* @param array<string, string>|null $declaredPaths kind => 절대 경로 (미지정 시 fixtureDir 의 부재 경로)
|
||||||
*/
|
*/
|
||||||
private function fakeExtensionWithAssets(string $identifier, int $priority, array $assets, string $strategy = 'global'): object
|
private function fakeExtensionWithAssets(
|
||||||
{
|
string $identifier,
|
||||||
|
int $priority,
|
||||||
|
array $assets,
|
||||||
|
string $strategy = 'global',
|
||||||
|
?array $declaredPaths = null
|
||||||
|
): object {
|
||||||
$ext = Mockery::mock();
|
$ext = Mockery::mock();
|
||||||
$ext->shouldReceive('hasAssets')->andReturn($assets !== []);
|
$ext->shouldReceive('hasAssets')->andReturn($assets !== []);
|
||||||
$ext->shouldReceive('getIdentifier')->andReturn($identifier);
|
$ext->shouldReceive('getIdentifier')->andReturn($identifier);
|
||||||
@@ -136,6 +146,12 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
array_map(fn () => 'dist/css/module.css', $assets)
|
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;
|
return $ext;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,10 +190,17 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
$this->assertSame(1, $service->countAssetDeclaringExtensions('module', 'css'));
|
$this->assertSame(1, $service->countAssetDeclaringExtensions('module', 'css'));
|
||||||
|
|
||||||
// 산출물은 하나도 존재하지 않는다 → 병합 결과는 빈 문자열.
|
// 산출물은 하나도 존재하지 않는다 → 병합 결과는 빈 문자열.
|
||||||
// "선언 > 0 && 결과 0" 이 곧 컨트롤러의 503 조건이다.
|
// 이 값은 **선언 축**이다 — 컨트롤러의 503 판정은 소실 축(findMissingDeclaredAssets)이
|
||||||
|
// 한다. 선언 > 0 그 자체는 장애 조건이 아니다(존재하되 비어 있으면 정상 빈 200).
|
||||||
$this->assertSame('', $service->getBundleFilePath('module', 'js', 12345));
|
$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
|
public function test_orders_global_assets_by_priority_ascending(): void
|
||||||
{
|
{
|
||||||
$a = $this->writeFixture('a.js', '(function(){})()');
|
$a = $this->writeFixture('a.js', '(function(){})()');
|
||||||
@@ -195,6 +218,11 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
$this->assertSame(['ext-a', 'ext-b', 'ext-c'], array_keys($ordered));
|
$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
|
public function test_skips_non_global_strategy(): void
|
||||||
{
|
{
|
||||||
$g = $this->writeFixture('g.js', '(function(){})()');
|
$g = $this->writeFixture('g.js', '(function(){})()');
|
||||||
@@ -211,6 +239,11 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
$this->assertArrayNotHasKey('ext-layout', $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
|
public function test_js_bundle_joins_iife_with_semicolon_newline_separator(): void
|
||||||
{
|
{
|
||||||
// 세미콜론 없이 끝나는 IIFE 2개 (ecommerce 형태)
|
// 세미콜론 없이 끝나는 IIFE 2개 (ecommerce 형태)
|
||||||
@@ -232,6 +265,9 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
$this->assertLessThan(strpos($js, 'window.B=2'), strpos($js, 'window.A=1'));
|
$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
|
public function test_prod_strips_source_mapping_url(): void
|
||||||
{
|
{
|
||||||
$this->app['env'] = 'production';
|
$this->app['env'] = 'production';
|
||||||
@@ -248,6 +284,9 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
$this->assertStringNotContainsString('sourceMappingURL', $js);
|
$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
|
public function test_dev_rewrites_source_mapping_url_to_asset_serving_path(): void
|
||||||
{
|
{
|
||||||
// 자산 URL 표기(경로형 vs `?file=` 쿼리형)는 사이트 설정이 정한다. 이 테스트가 보려는
|
// 자산 URL 표기(경로형 vs `?file=` 쿼리형)는 사이트 설정이 정한다. 이 테스트가 보려는
|
||||||
@@ -300,6 +339,11 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
$this->assertStringContainsString('sourceMappingURL', $bundle);
|
$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
|
public function test_per_extension_fault_tolerance_skips_missing_file(): void
|
||||||
{
|
{
|
||||||
$good = $this->writeFixture('good.js', '(function(){window.GOOD=1})()');
|
$good = $this->writeFixture('good.js', '(function(){window.GOOD=1})()');
|
||||||
@@ -323,6 +367,8 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
* 적었지만, 번들 URL 이 내려오면 프론트는 개별 로딩을 아예 타지 않는다
|
* 적었지만, 번들 URL 이 내려오면 프론트는 개별 로딩을 아예 타지 않는다
|
||||||
* (TemplateApp.loadExtensionAssets). 즉 제외 = 그 확장 스타일이 하나도 적용되지 않음
|
* (TemplateApp.loadExtensionAssets). 즉 제외 = 그 확장 스타일이 하나도 적용되지 않음
|
||||||
* 이었고, 오류도 로그 흔적도 화면 경고도 남지 않았다.
|
* 이었고, 오류도 로그 흔적도 화면 경고도 남지 않았다.
|
||||||
|
*
|
||||||
|
* @effects css_with_relative_url_rewritten_not_excluded
|
||||||
*/
|
*/
|
||||||
public function test_css_with_relative_url_is_rewritten_not_excluded(): void
|
public function test_css_with_relative_url_is_rewritten_not_excluded(): void
|
||||||
{
|
{
|
||||||
@@ -370,6 +416,9 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
$this->assertStringContainsString('url(data:image/gif;base64,AA==)', $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
|
public function test_empty_bundle_returns_empty_path(): void
|
||||||
{
|
{
|
||||||
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
|
||||||
@@ -379,6 +428,9 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
$this->assertSame('', $path);
|
$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
|
public function test_prod_writes_versioned_cache_file_and_reuses_it(): void
|
||||||
{
|
{
|
||||||
$this->app['env'] = 'production';
|
$this->app['env'] = 'production';
|
||||||
@@ -402,6 +454,42 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
$this->assertSame($path1, $path2);
|
$this->assertSame($path1, $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
|
public function test_cleanup_removes_stale_version_bundles_only(): void
|
||||||
{
|
{
|
||||||
$bundleDir = storage_path('app/ext-bundles');
|
$bundleDir = storage_path('app/ext-bundles');
|
||||||
@@ -421,6 +509,9 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
$this->assertFileDoesNotExist($bundleDir.'/plugin.100.css');
|
$this->assertFileDoesNotExist($bundleDir.'/plugin.100.css');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @effects clear_bundles_by_type_scopes_to_prefix
|
||||||
|
*/
|
||||||
public function test_clear_bundles_by_type(): void
|
public function test_clear_bundles_by_type(): void
|
||||||
{
|
{
|
||||||
$bundleDir = storage_path('app/ext-bundles');
|
$bundleDir = storage_path('app/ext-bundles');
|
||||||
@@ -498,6 +589,9 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @effects plugin_bundle_gdpr_first_when_priority_lowest
|
||||||
|
*/
|
||||||
public function test_plugin_bundle_orders_by_priority_gdpr_first_when_lowest(): void
|
public function test_plugin_bundle_orders_by_priority_gdpr_first_when_lowest(): void
|
||||||
{
|
{
|
||||||
// gdpr 가 priority 50 으로 최상단(제약 1 회귀 가드) — 이름 하드코딩 아닌 선언 결과
|
// gdpr 가 priority 50 으로 최상단(제약 1 회귀 가드) — 이름 하드코딩 아닌 선언 결과
|
||||||
@@ -516,4 +610,105 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
$js = $this->service()->buildJsBundle('plugin');
|
$js = $this->service()->buildJsBundle('plugin');
|
||||||
$this->assertLessThan(strpos($js, 'window.OTHER=1'), strpos($js, 'window.GDPR=1'));
|
$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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# audit:allow test-scenario-coverage reason: 서버측 확장 번들 병합 도입 회귀 가드. 입력 axis(활성 조합·환경·에셋 종류)의 cross-product 는 test_files 의 Unit/Feature 가 cover 하고, effects 는 병합 계약(순서/구분자/소스맵/캐시/폴백)과 브라우저 실측(요청수/gdpr race)을 enumerate.
|
# audit:allow test-scenario-coverage[cross_product] reason: 입력 axis 의 완전 cross-product(11축)는 조합 폭발이라 대표 조합만 마킹한다. effects 축은 면제하지 않는다 — 모든 effect 가 테스트 마킹으로 도달해야 한다.
|
||||||
|
|
||||||
feature: 확장(모듈/플러그인) 프론트엔드 IIFE/CSS 서버측 번들 병합
|
feature: 확장(모듈/플러그인) 프론트엔드 IIFE/CSS 서버측 번들 병합
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ axes:
|
|||||||
version_state: [first_request, cache_hit, version_bumped]
|
version_state: [first_request, cache_hit, version_bumped]
|
||||||
source_map_state: [present, absent]
|
source_map_state: [present, absent]
|
||||||
css_url_state: [no_url, absolute_url, relative_url]
|
css_url_state: [no_url, absolute_url, relative_url]
|
||||||
file_state: [all_present, one_missing]
|
file_state: [all_present, present_empty, one_missing]
|
||||||
priority_order: [ascending, gdpr_lowest]
|
priority_order: [ascending, gdpr_lowest]
|
||||||
bundle_urls_present: [present, absent_fallback]
|
bundle_urls_present: [present, absent_fallback]
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ effects:
|
|||||||
- prod_strips_source_mapping_url
|
- prod_strips_source_mapping_url
|
||||||
- dev_rewrites_source_mapping_url_to_asset_serving_path
|
- dev_rewrites_source_mapping_url_to_asset_serving_path
|
||||||
- per_extension_fault_tolerance_skips_missing_file_keeps_rest
|
- per_extension_fault_tolerance_skips_missing_file_keeps_rest
|
||||||
- css_with_relative_url_excluded_from_bundle
|
- css_with_relative_url_rewritten_not_excluded
|
||||||
- empty_bundle_returns_empty_path_and_empty_ok_response
|
- empty_bundle_returns_empty_path_and_empty_ok_response
|
||||||
- prod_writes_versioned_cache_file_and_reuses_on_same_version
|
- prod_writes_versioned_cache_file_and_reuses_on_same_version
|
||||||
- non_production_rebuilds_every_request_no_disk_cache
|
- non_production_rebuilds_every_request_no_disk_cache
|
||||||
@@ -56,9 +56,21 @@ effects:
|
|||||||
- individual_iife_requests_absent_in_browser
|
- individual_iife_requests_absent_in_browser
|
||||||
- gdpr_interceptor_still_active_after_merge
|
- gdpr_interceptor_still_active_after_merge
|
||||||
- extension_handlers_registered_no_console_error
|
- extension_handlers_registered_no_console_error
|
||||||
|
- bundle_empty_with_declared_assets_returns_503
|
||||||
|
- asset_declaration_count_reads_manifest_not_built_files
|
||||||
|
- bundle_decision_is_shared_across_extension_types
|
||||||
|
- bundle_disk_write_failure_falls_back_to_memory
|
||||||
|
- bundle_script_static_miss_falls_back_to_api
|
||||||
|
- empty_result_with_present_empty_artifacts_returns_200
|
||||||
|
- empty_result_with_missing_declared_artifact_returns_503
|
||||||
|
- missing_artifact_paths_are_logged
|
||||||
|
- bundle_css_failure_banner_uses_user_vocabulary
|
||||||
|
|
||||||
test_files:
|
test_files:
|
||||||
- tests/Unit/Services/ExtensionBundleServiceTest.php
|
- tests/Unit/Services/ExtensionBundleServiceTest.php
|
||||||
|
- tests/Unit/Extension/AbstractModuleDeclaredAssetsTest.php
|
||||||
|
- tests/Unit/Extension/AbstractPluginDeclaredAssetsTest.php
|
||||||
|
- tests/Unit/Extension/BundledManifestAssetDeclarationTest.php
|
||||||
- tests/Feature/Api/Public/ExtensionBundleServingTest.php
|
- tests/Feature/Api/Public/ExtensionBundleServingTest.php
|
||||||
- resources/js/core/modules/__tests__/ModuleAssetLoader.test.ts
|
- resources/js/core/modules/__tests__/ModuleAssetLoader.test.ts
|
||||||
- tests/Playwright/specs/extension-bundle-loading.spec.ts
|
- tests/Playwright/specs/extension-bundle-loading.spec.ts
|
||||||
|
|||||||
Reference in New Issue
Block a user