Files
Gnuboard7/tests/Unit/Build/ViteOutDirContractTest.php
T
HeuJung b4ed33aa44 feat(core): 정적 게시 수명주기 보완 — 빌드 파괴·권한·무결성
https://github.com/gnuboard/g7/issues/122 제보자의 추가 지적 2건(빌드가 게시본을
지움 / CLI 최초 게시 후 웹 재생성 불가)에 대응하고, 같은 근본 원인을 공유하는
결함을 전수조사로 함께 고친다.

- 빌드가 자기 산출물만 교체한다: 루트 vite `emptyOutDir: false`
 기본값 true 는 폴백 없는 코어 3번들과 배달된 immutable URL 의 게시본을 함께 지웠다
- 게시 트리 권한을 웹이 이어받는다: 병합 전 프리플라이트 + 부모 그룹 상속·g+w
 종전 소유권 정상화는 root 축만 덮어 비-root CLI 계정은 무방비였다
- 갱신 → 생성 → 완전성 확인을 한 묶음으로: 기록 바이트 대조, .old 원자 스왑,
 rename 일시 거부 재시도, 프론트 JSON 파싱 검증
- 실패를 억제하고 기록한다: 버전 한정 실패 마커 + 사유별 대시보드 알림
 + ext-static:status 점검 커맨드
- 파생: catch-all 제외 목록을 에셋 화이트리스트 합집합에서 파생(mjs·webp·otf 누락),
 번들 디스크 쓰기 fail-soft·빈 번들 503, 활성 디렉토리 prune 회피
2026-08-28 08:02:22 +09:00

124 lines
4.7 KiB
PHP

<?php
namespace Tests\Unit\Build;
use Tests\TestCase;
/**
* 빌드 산출물 디렉토리 계약 테스트 (#122 B1).
*
* vite 의 `build.emptyOutDir` 기본값은 `true` 다 — 산출물 디렉토리를 통째로 비운다.
* 그런데 그 디렉토리에는 vite 가 만들지 않는 **서빙 자산**이 함께 산다:
* `public/build/core/` 3번들(폴백 없는 동기 classic 스크립트), `public/build/ext/{v}/`
* 게시본(이미 배달된 HTML 의 immutable URL), 확장 `dist/vendor/`(동봉 제3자 자산).
* 소실은 예외도 서버 로그도 남기지 않고 브라우저 404 로만 나타난다.
*
* 픽스처 기반 정적 검사가 판정식을 잠그고, 이 테스트는 **저장소의 실제 상태**를
* 계약으로 고정한다 — git 추적 대상 vite config 전부가 `emptyOutDir` 을 명시 선언하고
* 그 값이 `false` 여야 한다.
*/
class ViteOutDirContractTest extends TestCase
{
/**
* 모든 git 추적 vite config 가 `emptyOutDir: false` 를 명시 선언한다.
*
* @effects vite_configs_declare_empty_outdir_false
*/
public function test_every_tracked_vite_config_declares_empty_outdir_false(): void
{
$configs = $this->trackedViteConfigs();
$this->assertNotEmpty($configs, 'vite config 를 하나도 찾지 못했다 — 이 계약 검사가 공허하다');
$this->assertContains(
'vite.config.js',
$configs,
'루트 vite config 가 목록에 없다 — 열거가 저장소 실제 상태를 반영하지 못한다'
);
foreach ($configs as $relative) {
$source = $this->stripComments((string) file_get_contents(base_path($relative)));
$this->assertMatchesRegularExpression(
'/\bemptyOutDir\s*:/',
$source,
"{$relative} 이 emptyOutDir 을 선언하지 않았다 — 기본값 true 로 산출물 디렉토리를 통째로 비운다"
);
preg_match_all('/\bemptyOutDir\s*:\s*([A-Za-z0-9_.]+)/', $source, $matches);
foreach ($matches[1] as $value) {
$this->assertSame(
'false',
$value,
"{$relative} 의 emptyOutDir 이 false 가 아니다 — 함께 사는 서빙 자산(코어 3번들 · 게시본 · 동봉 vendor)이 빌드마다 소실된다"
);
}
}
}
/**
* git 추적 대상 vite config 의 저장소 상대 경로 목록을 반환합니다.
*
* git 을 쓸 수 없는 환경에서는 추적 대상이 실제로 놓이는 위치(루트 · `_bundled`)를
* 직접 훑는다 — 활성 확장 디렉토리 사본(git 미추적)은 계약 대상이 아니다.
*
* @return array<int, string> 상대 경로 (구분자 `/`)
*/
private function trackedViteConfigs(): array
{
$output = [];
$status = 1;
@exec('git -C '.escapeshellarg(base_path()).' ls-files -- '.escapeshellarg('*vite.config*'), $output, $status);
if ($status === 0 && $output !== []) {
$tracked = array_values(array_filter(
array_map(fn ($line) => trim(str_replace('\\', '/', (string) $line)), $output),
fn ($line) => (bool) preg_match('#(^|/)vite\.config[^/]*\.(js|ts)$#', $line)
));
if ($tracked !== []) {
sort($tracked);
return $tracked;
}
}
$patterns = [
base_path('vite.config*.js'),
base_path('vite.config*.ts'),
base_path('modules/_bundled/*/vite.config*.js'),
base_path('modules/_bundled/*/vite.config*.ts'),
base_path('plugins/_bundled/*/vite.config*.js'),
base_path('plugins/_bundled/*/vite.config*.ts'),
base_path('templates/_bundled/*/vite.config*.js'),
base_path('templates/_bundled/*/vite.config*.ts'),
];
$found = [];
foreach ($patterns as $pattern) {
foreach (glob($pattern) ?: [] as $path) {
$found[] = ltrim(str_replace('\\', '/', substr($path, strlen(base_path()))), '/');
}
}
sort($found);
return $found;
}
/**
* 주석을 제거합니다 — 문서 주석 안의 예시 표기가 선언으로 오인되지 않게 한다.
*
* @param string $source 원본 소스
* @return string 주석이 제거된 소스
*/
private function stripComments(string $source): string
{
$withoutBlocks = preg_replace('#/\*.*?\*/#s', '', $source) ?? $source;
return preg_replace('#//[^\n]*#', '', $withoutBlocks) ?? $withoutBlocks;
}
}