Merge branch 'develop'
This commit is contained in:
commit
61608ca341
63 files changed
+3872
-1747
No files matched your search
+1
-1
@@ -3,7 +3,7 @@ APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.0
|
||||
APP_VERSION=7.0.1
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -3,7 +3,7 @@ APP_ENV=testing
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.0
|
||||
APP_VERSION=7.0.1
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -776,6 +776,18 @@ php artisan module:update sirsoft-ecommerce --force
|
||||
php artisan plugin:update sirsoft-payment --force
|
||||
```
|
||||
|
||||
### 코어 3-번들 구조 + 공유 런타임 (engine-v1.51.0+)
|
||||
|
||||
`core:build` 는 코어 프론트엔드를 3개 IIFE 번들로 빌드한다:
|
||||
|
||||
| 번들 | 로드 시점 | vite config |
|
||||
|------|----------|-------------|
|
||||
| `template-engine.min.js` | 모든 페이지 (동기 `<script>`) | `vite.config.core.js` |
|
||||
| `layout-editor.min.js` | `/admin/layout-editor/*` 진입 시 런타임 주입 | `vite.config.editor.js` |
|
||||
| `devtools.min.js` | 디버그 모드에서만 런타임 주입 | `vite.config.devtools.js` |
|
||||
|
||||
lazy 번들(편집기/devtools)이 코어 런타임(DynamicRenderer·엔진 싱글톤·React Context·DevTools 코어)을 재사용할 때는 재번들하지 않고 `window.G7Core.__runtime` 을 빌려 쓴다 — React/컨텍스트/싱글톤 인스턴스 동일성이 강제되기 때문(사본이 둘이면 "Invalid hook call"·컨텍스트 미매칭). 메인 번들이 `G7CoreGlobals` 에서 공유 대상을 `G7Core.__runtime` 에 노출하고, lazy 번들 vite config 는 React 4종(`react`/`react-dom`/`react-dom/client`/`react/jsx-runtime`)을 external→window 로, 코어 런타임 모듈을 `resolveId` 플러그인으로 `__runtime-shims/` 로 치환한다.
|
||||
|
||||
### 빌드 명령어 (Artisan)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -4,6 +4,18 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.1] - 2026-07-02
|
||||
|
||||
### Changed
|
||||
|
||||
- 첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Windows 환경에서 코어 업데이트가 `public/storage` 링크를 복사하는 단계에서 "디렉토리는 복사할 수 없습니다" 오류로 중단되던 문제를 수정했습니다. Windows의 저장소 링크(정션)가 이제 링크 그대로 안전하게 보존되어 업데이트가 정상 완료됩니다.
|
||||
- 별도 데이터 변환 작업이 필요 없는 버전으로 코어를 업데이트할 때, "실행할 업그레이드 단계가 없다"는 정상 상황을 실패로 표시하고 중단하던 문제를 수정했습니다. 이제 변환 작업이 없는 업데이트는 매끄럽게 완료되며, 이어서 설치된 확장의 최신 번들 반영 단계까지 정상적으로 진행됩니다.
|
||||
- 일부 호스팅 환경에서 관리자 환경설정의 보안 화면에 들어갈 때 시스템 정보 조회가 전체 실패(오류 응답)하던 문제를 수정했습니다. 이제 CPU·메모리·디스크 등 특정 항목을 서버 정책상 읽을 수 없더라도 그 항목만 "알 수 없음"으로 표시하고 나머지 시스템 정보는 정상적으로 보여줍니다. 읽지 못한 항목은 로그로 원인을 확인할 수 있습니다. (#59 @glitter-gim 님께서 제보해주셨습니다.)
|
||||
|
||||
## [7.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -246,7 +246,7 @@ unzip g7-release.zip
|
||||
|
||||
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
|
||||
ls -la
|
||||
# (필요 시) mv g7-7.0.0 g7
|
||||
# (필요 시) mv g7-7.0.1 g7
|
||||
|
||||
# ZIP 파일 정리 (선택)
|
||||
rm g7-release.zip
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.1-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
|
||||
@@ -85,27 +85,95 @@ class BuildCoreCommand extends Command
|
||||
*/
|
||||
private function buildEngineOnly(string $projectPath, bool $watchMode, bool $productionMode): int
|
||||
{
|
||||
$buildCommand = ['npm', 'run'];
|
||||
|
||||
// 감시 모드: 엔진 번들 + 편집기 번들(layout-editor.min.js)을 각각 vite --watch 로
|
||||
// 병렬 감시한다. (기존 dev 서버는 코어 lib 를 빌드하지 않으므로 사용 불가)
|
||||
if ($watchMode) {
|
||||
// build:core는 watch 모드가 없으므로 dev 사용
|
||||
$buildCommand[] = 'dev';
|
||||
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (템플릿 엔진)');
|
||||
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (템플릿 엔진 + 레이아웃 편집기)');
|
||||
$this->line(' Ctrl+C로 종료할 수 있습니다.');
|
||||
} else {
|
||||
$buildCommand[] = 'build:core';
|
||||
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
|
||||
return $this->runWatchBundles($projectPath);
|
||||
}
|
||||
|
||||
$result = $this->runNpmCommand($buildCommand, $projectPath, ! $watchMode);
|
||||
// ── 1) 템플릿 엔진 번들 (편집기 코드 제외) ──────────────────────────────
|
||||
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$engineResult = $this->runNpmCommand(['npm', 'run', 'build:core'], $projectPath, true);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info('✅ 코어 빌드 완료 (템플릿 엔진)');
|
||||
$this->showEngineBuildResults($projectPath);
|
||||
$this->incrementExtensionCacheVersion();
|
||||
if ($engineResult !== Command::SUCCESS) {
|
||||
return $engineResult;
|
||||
}
|
||||
|
||||
return $result;
|
||||
// ── 2) 레이아웃 편집기 번들 (lazy, /admin/layout-editor/* 진입 시 로드) ──
|
||||
$this->info('🔨 코어 빌드 시작 (레이아웃 편집기)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$editorResult = $this->runNpmCommand(['npm', 'run', 'build:core-editor'], $projectPath, true);
|
||||
|
||||
if ($editorResult !== Command::SUCCESS) {
|
||||
$this->error('❌ 레이아웃 편집기 번들 빌드 실패');
|
||||
|
||||
return $editorResult;
|
||||
}
|
||||
|
||||
// ── 3) DevTools 번들 (lazy, 디버그 모드에서만 로드) ──
|
||||
$this->info('🔨 코어 빌드 시작 (DevTools)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$devtoolsResult = $this->runNpmCommand(['npm', 'run', 'build:core-devtools'], $projectPath, true);
|
||||
|
||||
if ($devtoolsResult !== Command::SUCCESS) {
|
||||
$this->error('❌ DevTools 번들 빌드 실패');
|
||||
|
||||
return $devtoolsResult;
|
||||
}
|
||||
|
||||
$this->info('✅ 코어 빌드 완료 (템플릿 엔진 + 레이아웃 편집기 + DevTools)');
|
||||
$this->showEngineBuildResults($projectPath);
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 감시 모드에서 엔진 번들 + 편집기 번들을 병렬로 vite --watch 실행합니다.
|
||||
*
|
||||
* @param string $projectPath 프로젝트 경로
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runWatchBundles(string $projectPath): int
|
||||
{
|
||||
// 엔진 + 편집기 + DevTools 번들을 각각 vite --watch 로 병렬 감시
|
||||
$bundles = [
|
||||
'engine' => ['npm', 'run', 'build:core-watch'],
|
||||
'editor' => ['npm', 'run', 'build:core-editor-watch'],
|
||||
'devtools' => ['npm', 'run', 'build:core-devtools-watch'],
|
||||
];
|
||||
|
||||
/** @var array<string, Process> $processes */
|
||||
$processes = [];
|
||||
|
||||
foreach ($bundles as $label => $command) {
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
$command = array_merge(['cmd', '/c'], $command);
|
||||
}
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($projectPath);
|
||||
$process->setTimeout(null);
|
||||
$process->start(function ($type, $buffer) use ($label) {
|
||||
$this->output->write("[{$label}] ".$buffer);
|
||||
});
|
||||
|
||||
$processes[$label] = $process;
|
||||
}
|
||||
|
||||
// 하나라도 실행 중이면 계속 대기 (Ctrl+C 로 종료)
|
||||
do {
|
||||
$anyRunning = false;
|
||||
foreach ($processes as $process) {
|
||||
if ($process->isRunning()) {
|
||||
$anyRunning = true;
|
||||
}
|
||||
}
|
||||
usleep(100000); // 100ms
|
||||
} while ($anyRunning);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,12 +223,26 @@ class BuildCoreCommand extends Command
|
||||
|
||||
$this->line(' 빌드 결과:');
|
||||
|
||||
// template-engine.min.js 확인
|
||||
// template-engine.min.js 확인 (일반 페이지 초기 로드 = 이 번들만)
|
||||
$engineFile = $corePath.'/template-engine.min.js';
|
||||
if (file_exists($engineFile)) {
|
||||
$fileSize = number_format(filesize($engineFile) / 1024, 2);
|
||||
$this->line(" - template-engine.min.js ({$fileSize} KB)");
|
||||
}
|
||||
|
||||
// layout-editor.min.js 확인 (편집기 lazy 번들, /admin/layout-editor/* 진입 시 로드)
|
||||
$editorFile = $corePath.'/layout-editor.min.js';
|
||||
if (file_exists($editorFile)) {
|
||||
$fileSize = number_format(filesize($editorFile) / 1024, 2);
|
||||
$this->line(" - layout-editor.min.js ({$fileSize} KB, lazy)");
|
||||
}
|
||||
|
||||
// devtools.min.js 확인 (DevTools lazy 번들, 디버그 모드에서만 로드)
|
||||
$devtoolsFile = $corePath.'/devtools.min.js';
|
||||
if (file_exists($devtoolsFile)) {
|
||||
$fileSize = number_format(filesize($devtoolsFile) / 1024, 2);
|
||||
$this->line(" - devtools.min.js ({$fileSize} KB, lazy/debug)");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -806,6 +806,7 @@ class CoreUpdateCommand extends Command
|
||||
// 페이로드만 보관한다 (각각 UpgradeHandoffException 재구성 / silent skip 가드용).
|
||||
$handoffPayload = null;
|
||||
$stepsExecuted = null;
|
||||
$stepsDiscovered = null;
|
||||
while (! feof($pipes[1])) {
|
||||
$line = fgets($pipes[1]);
|
||||
if ($line !== false) {
|
||||
@@ -837,7 +838,13 @@ class CoreUpdateCommand extends Command
|
||||
$decoded = json_decode($json, true);
|
||||
if (is_array($decoded) && isset($decoded['count']) && is_int($decoded['count']) && $decoded['count'] >= 0) {
|
||||
$stepsExecuted = $decoded['count'];
|
||||
$log('[spawn] 실행된 step 수: '.$stepsExecuted);
|
||||
// discovered 는 신버전 자식만 발행하는 필드. 구버전 자식(필드 부재)은
|
||||
// null 로 남아 handleSpawnExit 가 기존(레거시) 판정 경로를 탄다.
|
||||
if (isset($decoded['discovered']) && is_int($decoded['discovered']) && $decoded['discovered'] >= 0) {
|
||||
$stepsDiscovered = $decoded['discovered'];
|
||||
}
|
||||
$log('[spawn] 실행된 step 수: '.$stepsExecuted
|
||||
.($stepsDiscovered !== null ? " (발견 {$stepsDiscovered})" : ''));
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -852,6 +859,50 @@ class CoreUpdateCommand extends Command
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
return $this->handleSpawnExit(
|
||||
$exitCode,
|
||||
$handoffPayload,
|
||||
$stepsExecuted,
|
||||
$stepsDiscovered,
|
||||
$fromVersion,
|
||||
$toVersion,
|
||||
$log,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* spawn 자식 종료 후 exit code · 신호를 해석해 성공/실패/핸드오프를 판정합니다.
|
||||
*
|
||||
* 판정 규칙 (exit=0 경우):
|
||||
* - `[STEPS_EXECUTED]` 신호 미수신 (`$stepsExecuted === null`) → fail-fast.
|
||||
* 이전 버전 자식 (신호 미발행) 또는 silent skip 의심.
|
||||
* - executed=0 인데 **discovered>0** → fail-fast. 범위 내에 스텝 파일이 실제로
|
||||
* 존재하는데 자식이 하나도 실행하지 못함 (gnuboard/g7#28 silent skip — 케이스 A).
|
||||
* - executed=0 이고 **discovered=0** → 정상 통과. 해당 from~to 범위에 스텝 파일이
|
||||
* 애초에 없는 릴리즈 (예: 데이터/설정 변경 없는 패치). from<to 여도 실패 아님 (케이스 B).
|
||||
* - discovered 신호 부재 (`null`, 구버전 자식) + executed=0 + from<to → 레거시 판정 유지
|
||||
* (fail-fast). 신버전 자식은 항상 discovered 를 발행하므로 이 경로는 구버전 자식 한정.
|
||||
*
|
||||
* @param int $exitCode 자식 프로세스 종료 코드
|
||||
* @param array|null $handoffPayload 파싱된 [HANDOFF] 페이로드 (없으면 null)
|
||||
* @param int|null $stepsExecuted 실행된 스텝 수 ([STEPS_EXECUTED] 미수신 시 null)
|
||||
* @param int|null $stepsDiscovered 범위 내 발견된 스텝 파일 수 (구버전 자식은 null)
|
||||
* @param string $fromVersion 업그레이드 시작 버전
|
||||
* @param string $toVersion 업그레이드 대상 버전
|
||||
* @param \Closure $log 로그 수집 콜백
|
||||
* @return bool spawn 성공 여부 (mode=fallback 실패 시 false — 호출자 in-process fallback)
|
||||
*
|
||||
* @throws UpgradeHandoffException 핸드오프 수신 또는 mode=abort 실패 시
|
||||
*/
|
||||
private function handleSpawnExit(
|
||||
int $exitCode,
|
||||
?array $handoffPayload,
|
||||
?int $stepsExecuted,
|
||||
?int $stepsDiscovered,
|
||||
string $fromVersion,
|
||||
string $toVersion,
|
||||
\Closure $log,
|
||||
): bool {
|
||||
if ($exitCode === UpgradeHandoffException::EXIT_CODE && $handoffPayload !== null) {
|
||||
$log("spawn 핸드오프 종료 (exit={$exitCode})");
|
||||
|
||||
@@ -866,9 +917,7 @@ class CoreUpdateCommand extends Command
|
||||
}
|
||||
|
||||
if ($exitCode === 0) {
|
||||
// silent skip 가드 — 자식이 [STEPS_EXECUTED] 신호를 발행하지 않거나, step 0건
|
||||
// 실행한 채 exit=0 으로 종료한 경우. 이전 버전 자식 (beta.5 이전 디스크) 또는
|
||||
// 자식이 비정상 종료 직전 silent skip 한 상태로 추정. fail-fast 모드 가드 적용.
|
||||
// 신호 미수신 — 이전 버전 자식 (신호 미발행) 또는 silent skip 의심. fail-fast.
|
||||
if ($stepsExecuted === null) {
|
||||
return $this->failSpawnWithMode(
|
||||
'spawn 자식이 [STEPS_EXECUTED] 신호 미발행 — 이전 버전 자식 또는 silent skip 의심',
|
||||
@@ -878,9 +927,27 @@ class CoreUpdateCommand extends Command
|
||||
);
|
||||
}
|
||||
|
||||
if ($stepsExecuted === 0 && version_compare($fromVersion, $toVersion, '<')) {
|
||||
// executed=0 + discovered=0 → 범위 내 스텝 파일 부재. 정상 통과 (케이스 B).
|
||||
// 스텝이 필요 없는 릴리즈(데이터/설정 변경 없음)를 실패로 오판하지 않는다.
|
||||
if ($stepsExecuted === 0 && $stepsDiscovered === 0) {
|
||||
$log("spawn 완료 (exit=0, 실행할 스텝 없음 — 범위 내 스텝 파일 부재, from={$fromVersion} to={$toVersion})");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// executed=0 + (discovered>0 또는 discovered 신호 부재) + from<to → fail-fast.
|
||||
// discovered>0: 스텝 파일이 있는데 자식이 실행 못함 (gnuboard/g7#28 케이스 A).
|
||||
// discovered=null: 구버전 자식 (discovered 미발행) — 레거시 판정 유지.
|
||||
if ($stepsExecuted === 0
|
||||
&& $stepsDiscovered !== 0
|
||||
&& version_compare($fromVersion, $toVersion, '<')) {
|
||||
return $this->failSpawnWithMode(
|
||||
sprintf('spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s)', $fromVersion, $toVersion),
|
||||
sprintf(
|
||||
'spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s%s)',
|
||||
$fromVersion,
|
||||
$toVersion,
|
||||
$stepsDiscovered !== null ? ", 발견 {$stepsDiscovered}건" : '',
|
||||
),
|
||||
$log,
|
||||
$fromVersion,
|
||||
$toVersion,
|
||||
|
||||
@@ -164,6 +164,7 @@ class ExecuteUpgradeStepsCommand extends Command
|
||||
}
|
||||
|
||||
$stepsExecuted = 0;
|
||||
$stepsDiscovered = 0;
|
||||
|
||||
try {
|
||||
$service->runUpgradeSteps(
|
||||
@@ -174,6 +175,9 @@ class ExecuteUpgradeStepsCommand extends Command
|
||||
$stepsExecuted++;
|
||||
},
|
||||
$force,
|
||||
function (int $discovered) use (&$stepsDiscovered): void {
|
||||
$stepsDiscovered = $discovered;
|
||||
},
|
||||
);
|
||||
} catch (UpgradeHandoffException $e) {
|
||||
// 업그레이드 스텝이 새 PHP 프로세스 재진입을 요청했다.
|
||||
@@ -221,6 +225,7 @@ class ExecuteUpgradeStepsCommand extends Command
|
||||
// mode=fallback 시 in-process 진행 (`spawn_failure_mode`).
|
||||
$this->line('[STEPS_EXECUTED] '.json_encode([
|
||||
'count' => $stepsExecuted,
|
||||
'discovered' => $stepsDiscovered,
|
||||
'fromVersion' => $from,
|
||||
'toVersion' => $to,
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
@@ -53,15 +53,25 @@ class FilePermissionHelper
|
||||
|
||||
$destPath = $destination.DIRECTORY_SEPARATOR.$itemName;
|
||||
|
||||
// symlink 자체 보존: target 추적 없이 동일 symlink 재생성.
|
||||
// symlink / Windows junction 자체 보존: target 추적 없이 동일 링크 재생성.
|
||||
// SplFileInfo::isDir() 가 symlink 를 추적하므로 검사 순서가 isDir() 보다 먼저여야 한다.
|
||||
// 미적용 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상.
|
||||
if ($item->isLink()) {
|
||||
if (static::copySymlink($item->getPathname(), $destPath)) {
|
||||
// 미적용 시 `public/storage` 등 링크가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상.
|
||||
//
|
||||
// Windows JUNCTION(`mklink /J`)은 reparse point 지만 PHP `isLink()`/`isDir()` 이 모두
|
||||
// false 를 반환한다(`storage:link` 가 Windows 에서 생성하는 형태). isLink() 만 검사하면
|
||||
// junction 이 파일로 오판되어 copyFile → `copy(디렉토리)` 로 코어 업데이트가 실패한다.
|
||||
// isReparsePoint() 로 junction 도 이 분기에 포함시킨다.
|
||||
if ($item->isLink() || static::isReparsePoint($item->getPathname())) {
|
||||
if (static::copyLink($item->getPathname(), $destPath)) {
|
||||
continue;
|
||||
}
|
||||
// 복원 실패 (Windows SeCreateSymbolicLink 권한 부족 등) — 일반 복사로 fall-through.
|
||||
// 단, junction 은 isDir() 이 false 라 아래 else 분기의 copyFile 로 새어 파일 복사가
|
||||
// 다시 실패하므로, fall-through 대신 skip 하여 손상 없이 넘어간다.
|
||||
// 운영자가 추후 `php artisan storage:link` 수동 실행으로 회복 가능.
|
||||
if (! $item->isLink()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($item->isDir()) {
|
||||
@@ -79,25 +89,63 @@ class FilePermissionHelper
|
||||
}
|
||||
|
||||
/**
|
||||
* symlink 자체를 보존 복사합니다 (target 미추적).
|
||||
* 경로가 Windows JUNCTION 등 PHP `isLink()` 가 인식하지 못하는 reparse point 인지 판정합니다.
|
||||
*
|
||||
* 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 symlink 재생성.
|
||||
* readlink / symlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through.
|
||||
* Windows JUNCTION(`mklink /J`)은 디렉토리 reparse point 로, `is_link()` / `is_file()` /
|
||||
* `is_dir()` 이 **모두** false 를 반환한다(그러나 경로 항목으로는 존재하며 `readlink()` 로
|
||||
* target 을 얻을 수 있다). `php artisan storage:link` 가 Windows 에서 생성하는
|
||||
* `public/storage` 가 이 형태다.
|
||||
*
|
||||
* Windows 환경에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하며 일반 사용자는
|
||||
* 권한이 없을 가능성이 높다. 실패 시 warning 로그 후 false 반환하여 호출자가 일반 복사로
|
||||
* fall-through 하도록 한다. 운영자는 업그레이드 후 `php artisan storage:link` 등 수동
|
||||
* 명령으로 symlink 를 회복할 수 있다.
|
||||
* 주의: Windows 의 `readlink()` 는 일반 파일/디렉토리에도 자기 경로를 반환하므로 junction
|
||||
* 판별 기준이 될 수 없다. 따라서 "존재하는 경로인데 link/file/dir 어디에도 해당하지 않음"
|
||||
* 을 판정 기준으로 삼는다. 여기에 추가로 `readlink()` 성공(!== false)을 요구해 존재하지
|
||||
* 않는 경로를 배제한다.
|
||||
*
|
||||
* @param string $source 소스 symlink 경로
|
||||
* @param string $destination 대상 symlink 경로
|
||||
* @return bool symlink 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백)
|
||||
* 일반 symlink(`is_link()` true)는 호출부가 별도로 처리하므로 여기서는 false 를 반환한다.
|
||||
* junction 은 Windows 전용이므로 비-Windows 에서는 항상 false.
|
||||
*
|
||||
* @param string $path 검사할 경로
|
||||
* @return bool junction 등 isLink() 미인식 reparse point 여부
|
||||
*/
|
||||
protected static function copySymlink(string $source, string $destination): bool
|
||||
protected static function isReparsePoint(string $path): bool
|
||||
{
|
||||
if (PHP_OS_FAMILY !== 'Windows') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 일반 symlink / 파일 / 디렉토리는 각 검사가 true → junction 아님.
|
||||
if (is_link($path) || is_file($path) || is_dir($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 위 셋에 모두 해당하지 않으면서 readlink 가 target 을 반환하면 junction(reparse point).
|
||||
return @readlink($path) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* symlink 또는 Windows junction 자체를 보존 복사합니다 (target 미추적).
|
||||
*
|
||||
* 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 링크 재생성.
|
||||
* readlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through.
|
||||
*
|
||||
* 재생성 전략:
|
||||
* - 일반 symlink: `symlink()`
|
||||
* - target 이 디렉토리인 경우(Windows junction 포함): `symlink()` 실패 시 `mklink /J` 로 폴백.
|
||||
* Windows 에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하지만 junction 은
|
||||
* 권한 없이 생성 가능하므로, junction 복원의 실질 경로가 된다.
|
||||
*
|
||||
* 모든 재생성 시도 실패 시 warning 로그 후 false 반환. 운영자는 업그레이드 후
|
||||
* `php artisan storage:link` 등 수동 명령으로 링크를 회복할 수 있다.
|
||||
*
|
||||
* @param string $source 소스 링크(symlink/junction) 경로
|
||||
* @param string $destination 대상 링크 경로
|
||||
* @return bool 링크 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백)
|
||||
*/
|
||||
protected static function copyLink(string $source, string $destination): bool
|
||||
{
|
||||
$target = @readlink($source);
|
||||
if ($target === false) {
|
||||
Log::warning('copySymlink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]);
|
||||
Log::warning('copyLink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -109,17 +157,29 @@ class FilePermissionHelper
|
||||
File::deleteDirectory($destination);
|
||||
}
|
||||
|
||||
if (! @symlink($target, $destination)) {
|
||||
Log::warning('copySymlink: symlink 생성 실패 — 일반 복사로 폴백', [
|
||||
'source' => $source,
|
||||
'destination' => $destination,
|
||||
'target' => $target,
|
||||
]);
|
||||
|
||||
return false;
|
||||
if (@symlink($target, $destination)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
// symlink 실패 + target 이 디렉토리 → Windows junction 으로 폴백 재생성.
|
||||
if (PHP_OS_FAMILY === 'Windows' && is_dir($target)) {
|
||||
exec(
|
||||
'cmd /c mklink /J '.escapeshellarg($destination).' '.escapeshellarg($target).' 2>&1',
|
||||
$out,
|
||||
$code
|
||||
);
|
||||
if ($code === 0 && is_dir($destination)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Log::warning('copyLink: 링크 생성 실패 — 일반 복사로 폴백', [
|
||||
'source' => $source,
|
||||
'destination' => $destination,
|
||||
'target' => $target,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,11 +217,19 @@ class FilePermissionHelper
|
||||
|
||||
// 소스에 존재하지 않는 항목만 삭제
|
||||
if (! File::exists($srcPath) && ! File::isDirectory($srcPath)) {
|
||||
// symlink 는 항상 unlink — File::deleteDirectory 는 is_dir() 추적 검사 후
|
||||
// 재귀 삭제하므로 symlink-to-dir 인 경우 target 의 모든 파일을 삭제하는 사고
|
||||
// 발생 가능. is_link() 가 isDir() 보다 먼저 평가되어야 한다.
|
||||
// symlink / Windows junction 은 링크 자체만 제거 — File::deleteDirectory 는 is_dir()
|
||||
// 추적 검사 후 재귀 삭제하므로 link-to-dir 인 경우 target 의 모든 파일을 삭제하는
|
||||
// 사고 발생 가능. 링크 검사가 isDir() 보다 먼저 평가되어야 한다.
|
||||
//
|
||||
// junction 은 is_link() 가 false 지만 isReparsePoint() 가 감지한다. junction 링크
|
||||
// 제거는 파일 대상 unlink 가 아닌 rmdir() 로 수행해야 target 내용을 보존한다.
|
||||
if (is_link($destItem->getPathname())) {
|
||||
@unlink($destItem->getPathname());
|
||||
} elseif (static::isReparsePoint($destItem->getPathname())) {
|
||||
// junction: rmdir 로 링크만 제거 (target 미추적). 폴백으로 unlink 시도.
|
||||
if (! @rmdir($destItem->getPathname())) {
|
||||
@unlink($destItem->getPathname());
|
||||
}
|
||||
} elseif ($destItem->isDir()) {
|
||||
File::deleteDirectory($destItem->getPathname());
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Requests\Settings\RegenerateAppKeyRequest;
|
||||
use App\Http\Requests\Settings\RestoreSettingsRequest;
|
||||
use App\Http\Requests\Settings\SaveSettingsRequest;
|
||||
use App\Http\Requests\Settings\TestDriverConnectionRequest;
|
||||
use App\Http\Requests\Settings\TestMailRequest;
|
||||
@@ -13,7 +14,7 @@ use App\Services\DriverConnectionTester;
|
||||
use App\Services\DriverRegistryService;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
@@ -137,7 +138,9 @@ class SettingsController extends AdminBaseController
|
||||
$systemInfo = $this->settingsService->getSystemInfo();
|
||||
|
||||
return $this->success('common.success', $systemInfo);
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('시스템 정보 조회 실패', ['error' => $e->getMessage()]);
|
||||
|
||||
return $this->error('common.error_occurred', 500, $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -264,16 +267,13 @@ class SettingsController extends AdminBaseController
|
||||
/**
|
||||
* 백업에서 설정을 복원합니다.
|
||||
*
|
||||
* @param RestoreSettingsRequest $request 복원 요청 데이터 (백업 경로)
|
||||
* @return JsonResponse 복원 결과 JSON 응답
|
||||
*/
|
||||
public function restore(Request $request): JsonResponse
|
||||
public function restore(RestoreSettingsRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$backupPath = $request->input('backup_path');
|
||||
|
||||
if (empty($backupPath)) {
|
||||
return $this->error('settings.backup_path_required', 422);
|
||||
}
|
||||
$backupPath = $request->validated('backup_path');
|
||||
|
||||
$result = $this->settingsService->restoreSettings($backupPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 설정 복원 요청 FormRequest
|
||||
*
|
||||
* 백업에서 설정을 복원할 때 백업 경로를 검증합니다.
|
||||
* 권한은 라우트의 permission 미들웨어(core.settings.update)가 담당합니다.
|
||||
*/
|
||||
class RestoreSettingsRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 요청 권한을 확인합니다.
|
||||
*
|
||||
* 권한 검사는 permission 미들웨어 체인에 위임하므로 항상 true 를 반환합니다.
|
||||
*
|
||||
* @return bool 항상 true
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 규칙을 반환합니다.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
'backup_path' => 'required|string',
|
||||
];
|
||||
|
||||
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
|
||||
return HookManager::applyFilters('core.settings.restore_validation_rules', $rules, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 오류 메시지를 반환합니다.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'backup_path.required' => __('settings.backup_path_required'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1558,8 +1558,9 @@ class CoreUpdateService
|
||||
* @param string $toVersion 종료 버전
|
||||
* @param \Closure|null $onStep 각 스텝 실행 시 콜백 (버전 문자열 전달)
|
||||
* @param bool $force true 시 fromVersion == toVersion이면 해당 버전 스텝도 포함
|
||||
* @param \Closure|null $onDiscovered 범위 내 발견된 스텝 파일 수를 실행 전 1회 통지 (int 전달)
|
||||
*/
|
||||
public function runUpgradeSteps(string $fromVersion, string $toVersion, ?\Closure $onStep = null, bool $force = false): void
|
||||
public function runUpgradeSteps(string $fromVersion, string $toVersion, ?\Closure $onStep = null, bool $force = false, ?\Closure $onDiscovered = null): void
|
||||
{
|
||||
// 부모 in-process fallback 진입 시 stale 메모리 가드.
|
||||
//
|
||||
@@ -1597,6 +1598,9 @@ class CoreUpdateService
|
||||
$upgradesPath = base_path('upgrades');
|
||||
|
||||
if (! File::isDirectory($upgradesPath)) {
|
||||
// upgrades 디렉토리 자체가 없으면 발견된 스텝 0건 — 정상 통지 후 종료.
|
||||
$onDiscovered?->__invoke(0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1656,6 +1660,12 @@ class CoreUpdateService
|
||||
|
||||
uksort($steps, 'version_compare');
|
||||
|
||||
// 범위 내에서 발견된 스텝 파일 수(discovered)를 먼저 통지한다.
|
||||
// 호출자(spawn 자식)는 이 값으로 "스텝 파일이 애초에 없어 0건(정상)" 과 "스텝 파일은
|
||||
// 있는데 실행이 0건(비정상 — gnuboard/g7#28 silent skip)" 을 구분한다. onStep 은 실제 실행
|
||||
// 건마다 호출되므로 executed 수만 세며, discovered 는 실행 전에 1회 통지한다.
|
||||
$onDiscovered?->__invoke(count($steps));
|
||||
|
||||
$context = new UpgradeContext($fromVersion, $toVersion);
|
||||
|
||||
foreach ($steps as $version => $step) {
|
||||
|
||||
@@ -822,30 +822,74 @@ class SettingsService
|
||||
private function buildSystemInfo(): array
|
||||
{
|
||||
return [
|
||||
'os_info' => php_uname('s').' '.php_uname('r'),
|
||||
// php_uname / ini_get 은 disable_functions 로 차단될 수 있어 개별 격리한다.
|
||||
'os_info' => $this->safeSystemProbe('os_info', fn () => php_uname('s').' '.php_uname('r'), __('common.unknown')),
|
||||
'web_server' => $_SERVER['SERVER_SOFTWARE'] ?? __('common.unknown'),
|
||||
'php_version' => PHP_VERSION,
|
||||
'mysql_version' => $this->getDatabaseInfo(),
|
||||
'mysql_version' => $this->safeSystemProbe('mysql_version', fn () => $this->getDatabaseInfo(), __('common.unknown')),
|
||||
'g7_version' => config('app.version', '1.0.0'),
|
||||
'g7_release_year' => config('app.release_year', '2026'),
|
||||
'laravel_version' => app()->version(),
|
||||
'environment' => app()->environment(),
|
||||
'cpu_info' => $this->getCpuInfo(),
|
||||
'memory_usage' => $this->getMemoryUsage(),
|
||||
'disk_usage' => $this->getDiskUsage(),
|
||||
'php_memory_limit' => ini_get('memory_limit'),
|
||||
'max_execution_time' => ini_get('max_execution_time').__('settings.seconds'),
|
||||
'upload_max_filesize' => ini_get('upload_max_filesize'),
|
||||
'cpu_info' => $this->safeSystemProbe('cpu_info', fn () => $this->getCpuInfo(), __('common.unknown')),
|
||||
'memory_usage' => $this->safeSystemProbe('memory_usage', fn () => $this->getMemoryUsage(), $this->unknownUsage()),
|
||||
'disk_usage' => $this->safeSystemProbe('disk_usage', fn () => $this->getDiskUsage(), $this->unknownUsage()),
|
||||
'php_memory_limit' => $this->safeSystemProbe('php_memory_limit', fn () => ini_get('memory_limit'), __('common.unknown')),
|
||||
'max_execution_time' => $this->safeSystemProbe('max_execution_time', fn () => ini_get('max_execution_time').__('settings.seconds'), __('common.unknown')),
|
||||
'upload_max_filesize' => $this->safeSystemProbe('upload_max_filesize', fn () => ini_get('upload_max_filesize'), __('common.unknown')),
|
||||
'install_path' => base_path(),
|
||||
'config_path' => storage_path('app/settings'),
|
||||
'log_path' => storage_path('logs'),
|
||||
'upload_path' => storage_path('app/public'),
|
||||
'php_extensions' => $this->getPhpExtensions(),
|
||||
'database_config' => $this->getDatabaseConfig(),
|
||||
'php_extensions' => $this->safeSystemProbe('php_extensions', fn () => $this->getPhpExtensions(), ['required' => [], 'optional' => []]),
|
||||
'database_config' => $this->safeSystemProbe('database_config', fn () => $this->getDatabaseConfig(), ['has_read_write_split' => false, 'write' => [], 'read' => []]),
|
||||
'timezone' => config('app.timezone'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 정보 probe 를 안전하게 실행합니다.
|
||||
*
|
||||
* probe 가 예외(ErrorException·Error·disable_functions 로 인한 Error 포함)를
|
||||
* 던지면 전파하지 않고 폴백값을 반환하고 경고 로그를 남깁니다. 개별 항목 수집
|
||||
* 실패가 system-info API 전체를 500 으로 만들지 않도록 격리합니다.
|
||||
*
|
||||
* @param string $label 실패 로그 식별용 항목명 (예: 'cpu_info')
|
||||
* @param callable $cb 실행할 probe 콜백
|
||||
* @param mixed $fallback 실패 시 반환할 폴백 값
|
||||
* @return mixed probe 결과 또는 폴백
|
||||
*/
|
||||
private function safeSystemProbe(string $label, callable $cb, mixed $fallback): mixed
|
||||
{
|
||||
try {
|
||||
return $cb();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('시스템 정보 수집 실패', [
|
||||
'item' => $label,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메모리/디스크 사용량 조회 실패 시 사용할 폴백 배열을 반환합니다.
|
||||
*
|
||||
* total/used/free/percentage 형식을 memory_usage·disk_usage 와 동일하게 유지합니다.
|
||||
*
|
||||
* @return array 사용량 폴백 배열
|
||||
*/
|
||||
private function unknownUsage(): array
|
||||
{
|
||||
return [
|
||||
'total' => __('common.unknown'),
|
||||
'used' => __('common.unknown'),
|
||||
'free' => __('common.unknown'),
|
||||
'percentage' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 캐시를 정리합니다.
|
||||
*
|
||||
@@ -949,11 +993,16 @@ class SettingsService
|
||||
try {
|
||||
$connection = DB::connection();
|
||||
$driver = $connection->getDriverName();
|
||||
$version = $connection->select('SELECT VERSION() as version')[0]->version ?? 'Unknown';
|
||||
$version = $connection->select('SELECT VERSION() as version')[0]->version ?? __('common.unknown');
|
||||
|
||||
return ucfirst($driver).' '.$version;
|
||||
} catch (\Exception $e) {
|
||||
return 'Unknown';
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('시스템 정보 수집 실패', [
|
||||
'item' => 'mysql_version',
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return __('common.unknown');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -999,12 +1048,7 @@ class SettingsService
|
||||
}
|
||||
|
||||
if ($total <= 0) {
|
||||
return [
|
||||
'total' => __('common.unknown'),
|
||||
'used' => __('common.unknown'),
|
||||
'free' => __('common.unknown'),
|
||||
'percentage' => 0,
|
||||
];
|
||||
return $this->unknownUsage();
|
||||
}
|
||||
|
||||
$used = max(0, $total - $free);
|
||||
@@ -1034,12 +1078,7 @@ class SettingsService
|
||||
$free = is_numeric($freeRaw) ? (int) $freeRaw : 0;
|
||||
|
||||
if ($total <= 0) {
|
||||
return [
|
||||
'total' => __('common.unknown'),
|
||||
'used' => __('common.unknown'),
|
||||
'free' => __('common.unknown'),
|
||||
'percentage' => 0,
|
||||
];
|
||||
return $this->unknownUsage();
|
||||
}
|
||||
|
||||
$used = max(0, $total - $free);
|
||||
@@ -1077,7 +1116,7 @@ class SettingsService
|
||||
*
|
||||
* @return string CPU 정보 문자열
|
||||
*/
|
||||
private function getCpuInfo(): string
|
||||
protected function getCpuInfo(): string
|
||||
{
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
$output = @shell_exec('powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Processor | Select-Object -First 1).Name" 2>&1');
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => env('APP_VERSION', '7.0.0'),
|
||||
'version' => env('APP_VERSION', '7.0.1'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.1] - 2026-07-02
|
||||
|
||||
### Added
|
||||
|
||||
- 설정 복원 관련 안내 메시지 일본어 번역 추가 (`settings.backup_path_required`·`restore_success`·`restore_failed`·`restore_error`) — 백업 경로 미입력 안내와 설정 복원 성공/실패 결과가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -145,4 +145,8 @@ return [
|
||||
'source_vendor_missing' => 'ソースディレクトリにvendorがありません。composer installが実行されていない可能性があります。',
|
||||
'composer_failed_with_output' => 'composer installの実行に失敗しました。:output',
|
||||
],
|
||||
'backup_path_required' => 'バックアップパスを入力してください。',
|
||||
'restore_success' => '設定が正常に復元されました。',
|
||||
'restore_failed' => '設定の復元に失敗しました。',
|
||||
'restore_error' => '設定の復元中にエラーが発生しました。',
|
||||
];
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 core Japanese language pack (bundled)",
|
||||
"ja": "G7 コア 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"scope": "core",
|
||||
"target_identifier": null,
|
||||
|
||||
@@ -21,6 +21,10 @@ return [
|
||||
'backup_success' => 'Database backup started successfully.',
|
||||
'backup_failed' => 'Failed to start database backup.',
|
||||
'backup_error' => 'An error occurred while backing up database.',
|
||||
'backup_path_required' => 'Please enter a backup path.',
|
||||
'restore_success' => 'Settings restored successfully.',
|
||||
'restore_failed' => 'Failed to restore settings.',
|
||||
'restore_error' => 'An error occurred while restoring settings.',
|
||||
'save_individual_failed' => 'Settings save failed: :error',
|
||||
|
||||
// App key related messages
|
||||
|
||||
@@ -21,6 +21,10 @@ return [
|
||||
'backup_success' => '데이터베이스 백업이 성공적으로 시작되었습니다.',
|
||||
'backup_failed' => '데이터베이스 백업에 실패했습니다.',
|
||||
'backup_error' => '데이터베이스 백업 중 오류가 발생했습니다.',
|
||||
'backup_path_required' => '백업 경로를 입력해주세요.',
|
||||
'restore_success' => '설정이 성공적으로 복원되었습니다.',
|
||||
'restore_failed' => '설정 복원에 실패했습니다.',
|
||||
'restore_error' => '설정 복원 중 오류가 발생했습니다.',
|
||||
'save_individual_failed' => '설정 저장에 실패했습니다: :error',
|
||||
|
||||
// 앱 키 관련 메시지
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:core": "vite build --config vite.config.core.js",
|
||||
"build:core-editor": "vite build --config vite.config.editor.js",
|
||||
"build:core-devtools": "vite build --config vite.config.devtools.js",
|
||||
"build:core-watch": "vite build --config vite.config.core.js --watch",
|
||||
"build:core-editor-watch": "vite build --config vite.config.editor.js --watch",
|
||||
"build:core-devtools-watch": "vite build --config vite.config.devtools.js --watch",
|
||||
"dev": "vite",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
|
||||
Vendored
+1561
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"devtools.min.js","sources":["../../../resources/js/core/devtools/__runtime-shims/G7DevToolsCore.ts","../../../resources/js/core/devtools/DiagnosticEngine.ts","../../../resources/js/core/devtools/ServerConnector.ts","../../../resources/js/core/devtools/StyleTracker.ts","../../../resources/js/core/devtools/ui/styles.ts","../../../resources/js/core/devtools/ui/constants.ts","../../../resources/js/core/devtools/ui/utils.ts","../../../resources/js/core/devtools/ui/components/JsonTreeNode.tsx","../../../resources/js/core/devtools/ui/tabs/StateTab.tsx","../../../resources/js/core/devtools/ui/tabs/ActionsTab.tsx","../../../resources/js/core/devtools/ui/tabs/CacheTab.tsx","../../../resources/js/core/devtools/ui/tabs/DiagnoseTab.tsx","../../../resources/js/core/devtools/ui/tabs/PerformanceTab.tsx","../../../resources/js/core/devtools/ui/tabs/LifecycleTab.tsx","../../../resources/js/core/devtools/ui/tabs/NetworkTab.tsx","../../../resources/js/core/devtools/ui/tabs/FormTab.tsx","../../../resources/js/core/devtools/ui/tabs/ConditionalTab.tsx","../../../resources/js/core/devtools/ui/tabs/ExpressionTab.tsx","../../../resources/js/core/devtools/ui/tabs/HandlersTab.tsx","../../../resources/js/core/devtools/ui/tabs/EventsTab.tsx","../../../resources/js/core/devtools/ui/tabs/DataSourcesTab.tsx","../../../resources/js/core/devtools/ui/tabs/RendersTab.tsx","../../../resources/js/core/devtools/ui/tabs/StylesTab.tsx","../../../resources/js/core/devtools/ui/tabs/AuthTab.tsx","../../../resources/js/core/devtools/ui/tabs/LogsTab.tsx","../../../resources/js/core/devtools/ui/tabs/LayoutTab.tsx","../../../resources/js/core/devtools/ui/tabs/ChangeDetectionTab.tsx","../../../resources/js/core/devtools/ui/tabs/StaleClosureTab.tsx","../../../resources/js/core/devtools/ui/tabs/NestedContextTab.tsx","../../../resources/js/core/devtools/ui/tabs/ModalScopeTab.tsx","../../../resources/js/core/devtools/ui/tabs/NamedActionsTab.tsx","../../../resources/js/core/devtools/ui/DevToolsPanel.tsx","../../../resources/js/core/devtools-entry.ts"],"sourcesContent":["/**\n * G7DevToolsCore 런타임 shim — DevTools lazy 번들 전용 (vite.config.devtools.js alias 대상)\n * @since engine-v1.52.0\n *\n * DevTools 추적 코어는 디버그 무관 항상 필요해 메인 번들에 남는다. 디버그 lazy 번들\n * (DevToolsPanel 등)은 이 shim 으로 메인 번들의 단일 인스턴스를 빌려 쓴다(싱글톤 동일성).\n * 값은 window.G7Core.__runtime, 타입은 원본에서 재export.\n */\n\nconst runtime = (typeof window !== 'undefined'\n ? (window as any).G7Core?.__runtime\n : undefined) as { G7DevToolsCore?: unknown } | undefined;\n\nif (!runtime?.G7DevToolsCore) {\n throw new Error(\n '[devtools] window.G7Core.__runtime.G7DevToolsCore 이 없습니다. ' +\n '코어 번들(template-engine.min.js)이 devtools 번들보다 먼저 로드되어야 합니다.'\n );\n}\n\nexport const G7DevToolsCore = runtime.G7DevToolsCore as typeof import('../G7DevToolsCore').G7DevToolsCore;\nexport default G7DevToolsCore;\n","/**\n * G7 DevTools 진단 엔진\n *\n * 증상 기반으로 템플릿 엔진 문제를 자동 진단하는 규칙 엔진\n *\n * @module DiagnosticEngine\n */\n\nimport type {\n DiagnosticRule,\n DiagnosticCategory,\n DiagnosticContext,\n DiagnosisResult,\n FixSuggestion,\n CommonIssue,\n StateSnapshot,\n ActionLog,\n CacheStats,\n StateView,\n LifecycleInfo,\n PerformanceInfo,\n NetworkInfo,\n ConditionalInfo,\n FormInfo,\n WebSocketInfo,\n} from './types';\nimport { G7DevToolsCore } from './G7DevToolsCore';\n\n/**\n * 진단 규칙 정의\n *\n * 트러블슈팅 가이드의 주요 이슈를 규칙으로 정의\n */\nconst diagnosticRules: DiagnosticRule[] = [\n // ============================================\n // 상태 관련 문제\n // ============================================\n {\n id: 'stale-closure',\n name: 'Stale Closure',\n category: 'state',\n symptoms: [\n '이전 값이 전송됨',\n '저장 시 오래된 데이터',\n 'API 호출 시 첫 값만 전송',\n '클릭할 때마다 같은 값',\n ],\n detector: (ctx) => {\n // 액션 이력에서 동일한 값이 반복되는 패턴 감지\n const recentActions = ctx.actionHistory.slice(-10);\n const apiCalls = recentActions.filter(a => a.type === 'apiCall');\n if (apiCalls.length < 2) return false;\n\n // 연속된 API 호출에서 동일한 파라미터가 사용되는지 확인\n for (let i = 1; i < apiCalls.length; i++) {\n const prevParams = JSON.stringify(apiCalls[i - 1].params);\n const currParams = JSON.stringify(apiCalls[i].params);\n if (prevParams === currParams && apiCalls[i].params) {\n return true;\n }\n }\n return fLine truncated
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
var Rz=Object.defineProperty;var eM=(ln,i,u)=>i in ln?Rz(ln,i,{enumerable:!0,configurable:!0,writable:!0,value:u}):ln[i]=u;var Ii=(ln,i,u)=>eM(ln,typeof i!="symbol"?i+"":i,u);(function(ln,i,u,Sn){"use strict";function Lt(){const e=typeof window<"u"?window.G7Core?.__runtime:void 0;if(!e)throw new Error("[layout-editor] window.G7Core.__runtime 이 없습니다. 코어 번들(template-engine.min.js)이 편집기 번들보다 먼저 로드되어야 합니다.");return e}const Ai=Lt();Ai.TranslationProvider;const tt=Ai.useTranslation;Ai.TranslationReactContext;function rt(){return typeof window>"u"?null:window.__g7Devtools??null}function wl(e,t){const n=rt();if(!n?.trackEditorState)return;const o={templateIdentifier:e.templateIdentifier,editMode:e.editMode,selectedRoutePath:e.selectedRoute?.path??null,selectedLayoutName:e.selectedRoute?.layoutName??null,routeTreeSize:e.routeTree.length,isRouteTreeCollapsed:e.isRouteTreeCollapsed,locale:e.locale,previewDevice:e.previewDevice,previewZoom:e.previewZoom,lastAction:t,timestamp:Date.now()};try{n.trackEditorState(o)}catch{}}function Bh(){const e=rt();if(e?.clearEditorStateData)try{e.clearEditorStateData()}catch{}}function Wh(e){const t=rt();if(t?.trackEditorSampleMatch)try{t.trackEditorSampleMatch(e)}catch{}}function jh(){const e=rt();if(e?.clearEditorSampleData)try{e.clearEditorSampleData()}catch{}}function Hh(e){const t=rt();if(t?.trackEditorHistoryEntry)try{t.trackEditorHistoryEntry(e)}catch{}}function Fh(){const e=rt();if(e?.clearEditorHistoryData)try{e.clearEditorHistoryData()}catch{}}function kl(e){const t=rt();if(t?.trackEditorSelection)try{t.trackEditorSelection(e)}catch{}}function Kh(){const e=rt();if(e?.clearEditorSelectionData)try{e.clearEditorSelectionData()}catch{}}function or(e){const t=rt();if(t?.trackEditorPropertyPatch)try{t.trackEditorPropertyPatch(e)}catch{}}function Cn(e){const t=rt();if(t?.trackEditorDnd)try{t.trackEditorDnd(e)}catch{}}function Vh(){const e=rt();if(e?.clearEditorDndData)try{e.clearEditorDndData()}catch{}}function st(e){const t=rt();if(t?.trackEditorDocument)try{t.trackEditorDocument(e)}catch{}}function Uh(){const e=rt();if(e?.clearEditorDocumentData)try{e.clearEditorDocumentData()}catch{}}function qh(e){const t=rt();if(t?.trackEditorSpecMerge)try{t.trackEditorSpecMerge(e)}catch{}}function Gh(){const e=rt();if(e?.clearEditorSpecMergeData)try{e.clearEditorSpecMergeData()}catch{}}function Ti(e){let t=5381;for(let n=0;n<e.length;n++)t=(t<<5)+t+e.charCodeAt(n)>>>0;return t}function Et(e){const t=rt();if(t?.trackEditorI18n)try{t.trackEditorI18n(e)}catch{}}function Yh(){const e=rt();if(e?.clearEditorI18nData)try{e.clearEditorI18nData()}catch{}}function Sl(e){const t=rt();if(t?.trackPageState)try{t.trackPageState(e)}catch{}}function Xh(){const e=rt();if(e?.clearPageStateData)try{e.clearPageStateData()}catch{}}const Cl="g7le.routeTree.collapsed";function Jh(){if(typeof window>"u")return!1;try{return window.localStorage?.getItem(Cl)==="1"}catch{return!1}}function Zh(e){if(!(typeof window>"u"))try{window.localStorage?.setItem(Cl,e?"1":"0")}catch{}}function Qh(e,t){return{templateIdentifier:e,routeTree:[],routesError:null,selectedRoute:null,isRouteTreeCollapsed:Jh(),editMode:"route",returnRoute:null,returnEditMode:null,locale:t,previewDevice:"desktop",previewZoom:1,previewCustomWidth:1024,previewColorScheme:"light",history:{canUndo:!1,canRedo:!1},isPaletteOpen:!1,availableStates:[],activeStateId:null,layoutVersions:{},extensionVersions:{}}}function Rh(e,t){switch(t.type){case"SET_ROUTE_TREE":return{...e,routeTree:t.tree,routesError:null};case"SET_ROUTES_ERROR":return{...e,routesError:t.error};case"SELECT_ROUTE":return{...e,editMode:"route",selectedRoute:t.route,returnRoute:null,returnEditMode:null};case"TOGGLE_ROUTE_TREE":return{...e,isRouteTreeCollapsed:!e.isRouteTreeCollapsed};case"SET_LOCALE":return{...e,locale:t.locale};case"ENTER_BASE_EDIT":return{...e,editMode:"base",returnRoute:t.returnRoute!==void 0?t.returnRoute:e.selectedRoute,selectedRoute:{path:`__base__/${t.layoutName}`,layoutName:t.layoutName}};case"EXIT_BASE_EDIT":return{...e,editMode:"route",selectedRoute:e.returnRoute,returnRoute:null};case"ENTER_MODAL_EDIT":return{...e,editMode:"modal",returnRoute:t.returnRoute!==void 0?t.returnRoute:e.selectedRoute,selectedRoute:{path:`__modal__/${t.modalId}`,layoutName:t.hostLayout}};case"EXIT_MODAL_EDIT":return{...e,editMode:"route",selectedRoute:e.returnRoute,returnRoute:null};case"ENTER_EXTENSION_EDIT":return{...e,editMode:"extension",returnRoute:t.returnRoute!==void 0?t.returnRoute:e.selectedRoute,selectedRoute:{path:`__extension__/${t.extensionId}`,layoutName:t.extensionHost??null}};case"SET_RETURN_ROUTE":return e.editMode==="route"||e.returnRoute!==null?e:{...e,returnRoute:t.route};case"EXIT_EXTENSION_EDIT":return{...e,editMode:"route",selectedRoute:e.returnRoute,returnRoute:null};case"ENTER_ITERATION_ITEM_EDIT":return{...e,editMode:"iteration_item",returnRoute:t.returnRoute!==void 0?t.returnRoute:e.selectedRoute,returnEditMode:e.ediLine truncated
|
||||
"keyword": "",
|
||||
"page": 1
|
||||
}`,shapeErrorKey:"layout_editor.json_block.must_be_object",validate:_=>{const S=Object.keys(_??{}).filter(E=>!$s(E));return S.length?a("layout_editor.json_block.invalid_keys",{keys:S.join(", ")}):null}}):i.jsxs(i.Fragment,{children:[v.length===0?i.jsx("p",{"data-testid":`g7le-initstate-${e}-empty`,style:ES,children:a("layout_editor.initstate.no_values")}):v.map(_=>{const E=Ew(b,r,_)==="inherited",C=e==="isolated"&&l[_];return i.jsx(aS,{scope:e,itemKey:_,value:o[_],inherited:E,isOrphan:!!C,onChangeValue:N=>x(_,N),onRemove:()=>k(_),t:a},_)}),i.jsxs("div",{style:xS,children:[i.jsx("input",{type:"text","data-testid":`g7le-initstate-${e}-add-name`,value:d,placeholder:a("layout_editor.initstate.value_name"),onChange:_=>{c(_.target.value),y&&g(null)},onKeyDown:_=>{_.key==="Enter"&&(_.preventDefault(),w())},"aria-invalid":y?!0:void 0,style:{...wS,...y?kS:null}}),i.jsx("select",{"data-testid":`g7le-initstate-${e}-add-kind`,value:p,onChange:_=>f(_.target.value),"aria-label":a("layout_editor.initstate.value_kind"),style:CS,children:iS.map(_=>i.jsx("option",{value:_.kind,children:a(_.labelKey)},_.kind))}),i.jsxs("button",{type:"button","data-testid":`g7le-initstate-${e}-add`,onClick:w,style:$S,children:["+ ",a("layout_editor.initstate.add_value")]})]}),y?i.jsx("p",{"data-testid":`g7le-initstate-${e}-add-error`,style:SS,children:y}):null]})]})}function aS({scope:e,itemKey:t,value:n,inherited:o,isOrphan:r,onChangeValue:s,onRemove:a,t:l}){const[d,c]=u.useState(!1);let p;try{p=JSON.stringify(n,null,2)??String(n)}catch{p=String(n)}return i.jsxs("div",{"data-testid":`g7le-initstate-${e}-item-${t}`,style:yS,children:[i.jsxs("div",{style:bS,children:[o?i.jsx("span",{"data-testid":`g7le-initstate-inherited-${t}`,style:mS,children:"🔗"}):null,i.jsx("code",{style:vS,children:t}),i.jsx("span",{style:{flex:1}}),i.jsx("button",{type:"button","data-testid":`g7le-initstate-code-${t}`,onClick:()=>c(f=>!f),style:Ws,"aria-label":l("layout_editor.value_tree.show_source"),title:l("layout_editor.value_tree.show_source"),children:"</>"}),o?i.jsx("button",{type:"button","data-testid":`g7le-initstate-revert-${t}`,onClick:a,style:Ws,children:l("layout_editor.initstate.revert")}):i.jsx("button",{type:"button","data-testid":`g7le-initstate-remove-${t}`,onClick:a,style:Ws,"aria-label":l("layout_editor.init_actions.remove"),children:"✕"})]}),d?i.jsx("pre",{"data-testid":`g7le-initstate-code-block-${t}`,style:AS,children:p}):null,i.jsx(Dn,{value:n,onChange:s,t:l,path:t,scope:e}),r?i.jsxs("p",{"data-testid":`g7le-initstate-isolated-orphan-${t}`,style:_S,children:["⚠ ",l("layout_editor.initstate.isolated_orphan")]}):null]})}const lS={display:"flex",flexDirection:"column",gap:14,minWidth:0},dS={margin:0,fontSize:13,fontWeight:700,color:"#0f172a"},cS={margin:0,fontSize:11,color:"#1d4ed8",background:"#eff6ff",padding:"6px 8px",borderRadius:6},uS={display:"flex",flexDirection:"column",gap:8,padding:10,border:"1px solid #e2e8f0",borderRadius:10,minWidth:0},pS={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8},fS={fontSize:12,fontWeight:700,color:"#475569"},gS={margin:0,fontSize:11,color:"#94a3b8"},hS={padding:"2px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},yS={display:"flex",flexDirection:"column",gap:4,padding:"6px 8px",border:"1px solid #f1f5f9",borderRadius:6,minWidth:0},bS={display:"flex",alignItems:"center",gap:6},mS={fontSize:12},vS={fontSize:12,color:"#0f172a",fontFamily:"monospace"},Ws={border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",fontSize:11,padding:"2px 6px"},_S={margin:0,fontSize:11,color:"#b45309",background:"#fef3c7",padding:"4px 6px",borderRadius:6},xS={display:"flex",gap:6,alignItems:"center"},wS={padding:"4px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},kS={border:"1px solid #dc2626"},SS={margin:0,fontSize:11,color:"#dc2626"},CS={padding:"4px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#0f172a"},$S={padding:"2px 10px",fontSize:11,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},ES={margin:0,fontSize:12,color:"#94a3b8"},IS={margin:0,fontSize:11,color:"#94a3b8"},AS={margin:"2px 0",padding:"6px 8px",fontSize:11,fontFamily:"monospace",color:"#0f172a",background:"#f1f5f9",border:"1px solid #e2e8f0",borderRadius:6,whiteSpace:"pre-wrap",wordBreak:"break-all",overflowX:"auto"},TS={self:"layout_editor.error_rows.source_self",inherited:"layout_editor.error_rows.source_inherited",template:"layout_editor.error_rows.source_template",none:"layout_editor.error_rows.source_none"};function js({value:e,onChange:t,t:n,codes:o,mode:r="local",sourceOf:s,renderActionList:a,testIdPrefix:l="g7le-error-rows"}){const d=o??Object.keys(e),c=u.useCallback((f,y)=>{t({...e,[f]:y})},[e,t]),p=u.useCallback(f=>{const y={...e};delete y[f],t(y)},[e,t]);return i.jsx("div",{className:l,"data-testid":l,children:d.map(f=>{const y=e[f],g=
Line truncated
|
||||
"page": "{{query.page ?? 1}}"
|
||||
}`,shapeErrorKey:"layout_editor.data_sources.error.params_object",invalidErrorKey:"layout_editor.data_sources.error.params_json",t:n,onChange:I=>x(P=>P&&{...P,paramsRawValue:I??{},paramsRawValid:!0}),onValidityChange:I=>x(P=>P&&{...P,paramsRawValid:I})}):i.jsxs("div",{"data-testid":"g7le-data-sources-field-params",children:[b.paramRows.length===0&&i.jsx("p",{"data-testid":"g7le-ds-params-empty",style:WC,children:n("layout_editor.data_sources.field.params_empty")}),i.jsx(Dr,{value:b.paramRows.map(I=>({key:I.key,value:I.value})),onChange:I=>x({...b,paramRows:I.map(P=>({key:P.key??"",value:P.value??""}))}),keyField:"key",valueField:"value",t:n,candidates:d,testidPrefix:"g7le-ds-params-kv",keyPlaceholder:n("layout_editor.data_sources.field.param_key_placeholder"),valuePlaceholder:"{{query.page ?? 1}}",addLabel:n("layout_editor.data_sources.field.param_add")}),(()=>{const I=qs(b.paramRows);return Object.keys(I).length===0?null:i.jsxs("div",{style:Au,children:[i.jsxs("button",{type:"button","data-testid":"g7le-ds-params-preview-toggle","aria-expanded":_,onClick:()=>S(P=>!P),style:Tu,children:[_?"▴":"▾"," ",n("layout_editor.data_sources.field.params_preview")]}),_&&i.jsx("pre",{"data-testid":"g7le-ds-params-preview",style:{...Du,marginTop:4},children:JSON.stringify(I,null,2)})]})})()]}),i.jsx("div",{style:{fontSize:11,color:"#94a3b8",marginTop:4},children:n("layout_editor.data_sources.field.params_hint")})]}),i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,children:n("layout_editor.data_sources.field.fallback")}),b.fallbackEnabled?i.jsxs("div",{"data-testid":"g7le-data-sources-field-fallback",style:{display:"flex",flexDirection:"column",gap:6},children:[i.jsx(Dn,{value:b.fallbackValue===void 0?{}:b.fallbackValue,onChange:I=>x({...b,fallbackValue:I}),t:n,path:"fallback",scope:"ds-fallback",renderStringLeaf:I=>i.jsx(Ve,{value:I.value,onChange:I.onChange,t:n,candidates:d,testidPrefix:I.testidPrefix})}),(()=>{const I=b.fallbackValue===void 0?{}:b.fallbackValue;return i.jsxs("div",{style:Au,children:[i.jsxs("button",{type:"button","data-testid":"g7le-ds-fallback-preview-toggle","aria-expanded":E,onClick:()=>C(P=>!P),style:Tu,children:[E?"▴":"▾"," ",n("layout_editor.data_sources.field.fallback_preview")]}),E&&i.jsx("pre",{"data-testid":"g7le-ds-fallback-preview",style:{...Du,marginTop:4},children:JSON.stringify(I,null,2)})]})})(),i.jsx("button",{type:"button","data-testid":"g7le-data-sources-field-fallback-clear",onClick:()=>x({...b,fallbackEnabled:!1,fallbackValue:void 0}),style:{...on,padding:"2px 8px",fontSize:11,alignSelf:"flex-start",color:"#dc2626",borderColor:"#fecaca"},children:n("layout_editor.data_sources.field.fallback_clear")})]}):i.jsxs("button",{type:"button","data-testid":"g7le-data-sources-field-fallback-add",onClick:()=>x({...b,fallbackEnabled:!0,fallbackValue:{}}),style:{...on,padding:"4px 10px",display:"block"},children:["+ ",n("layout_editor.data_sources.field.fallback_add")]})]}),b.type==="websocket"&&i.jsxs("div",{"data-testid":"g7le-ds-ws-section",style:To,children:[i.jsx("div",{style:Do,children:n("layout_editor.data_sources.section.websocket")}),i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,children:n("layout_editor.data_sources.field.channel")}),i.jsx("input",{"data-testid":"g7le-ds-ws-channel",style:jt,value:b.channel,onChange:I=>x({...b,channel:I.target.value}),placeholder:"core.admin.dashboard"})]}),i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,children:n("layout_editor.data_sources.field.event")}),i.jsx("input",{"data-testid":"g7le-ds-ws-event",style:jt,value:b.event,onChange:I=>x({...b,event:I.target.value}),placeholder:"dashboard.stats.updated"})]}),i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,children:n("layout_editor.data_sources.field.channel_type")}),i.jsx("select",{"data-testid":"g7le-ds-ws-channeltype",style:jt,value:b.channel_type,onChange:I=>x({...b,channel_type:I.target.value}),children:MC.map(I=>i.jsx("option",{value:I,children:I},I))})]}),i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,children:n("layout_editor.data_sources.field.target_source")}),i.jsx("input",{"data-testid":"g7le-ds-ws-target",style:jt,value:b.target_source,onChange:I=>x({...b,target_source:I.target.value}),list:"g7le-ds-ws-target-list"}),i.jsx("datalist",{id:"g7le-ds-ws-target-list",children:h.filter(I=>I.id!==b.id).map(I=>typeof I.id=="string"?i.jsx("option",{value:I.id},I.id):null)})]}),i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,children:n("layout_editor.data_sources.field.on_receive")}),i.jsx("div",{"data-testid":"g7le-ds-ws-onreceive",children:i.jsx(Gt,{actions:b.onReceive,onChange:I=>x({...b,onReceive:I}),t:n,recipes:s,candidatePools:z,chipContext:"payload",actionChipCandidates:l?.actionChipCandidates??null,testIdPrefix:"g7le-ds-onreceive-list"})})]})]}),i.jsxs("div",{style:To,children:[i.jsx("div",{style:Do,children:n("layout_editor.data_sources.section.on_success")}),i.jsx("div",{"data-testid":"g7le-ds-onsuccess",children:i.j
Line truncated
|
||||
`).split(`
|
||||
`);return t.length>0&&t[t.length-1]===""&&t.pop(),t}const Fu=4e3;function m$(e,t,n=3){const o=Hu(e),r=Hu(t);let s=0;const a=Math.min(o.length,r.length);for(;s<a&&o[s]===r[s];)s++;let l=0;const d=Math.min(o.length,r.length)-s;for(;l<d&&o[o.length-1-l]===r[r.length-1-l];)l++;const c=o.slice(s,o.length-l),p=r.slice(s,r.length-l);if(c.length>Fu||p.length>Fu)return{hunks:[],addedCount:-1,removedCount:-1,identical:!1};const f=y$(c,p),y=b$(c,p,f),g=[];let m=s,h=s,v=0,b=0;for(let w=0;w<s;w++)g.push({kind:"context",oldLine:w+1,newLine:w+1,content:o[w]});for(const w of y)w.kind==="context"?(m++,h++,g.push({kind:"context",oldLine:m,newLine:h,content:w.content})):w.kind==="remove"?(m++,b++,g.push({kind:"remove",oldLine:m,newLine:null,content:w.content})):(h++,v++,g.push({kind:"add",oldLine:null,newLine:h,content:w.content}));for(let w=0;w<l;w++){const _=o.length-l+w,S=r.length-l+w;g.push({kind:"context",oldLine:_+1,newLine:S+1,content:o[_]})}const x=v===0&&b===0;return{hunks:v$(g,n),addedCount:v,removedCount:b,identical:x}}function v$(e,t){const n=[];for(let r=0;r<e.length;r++)e[r].kind!=="context"&&n.push(r);if(n.length===0)return[];const o=[];for(const r of n){const s=Math.max(0,r-t),a=Math.min(e.length-1,r+t),l=o[o.length-1];l&&s<=l.end+1?l.end=Math.max(l.end,a):o.push({start:s,end:a})}return o.map(r=>{const s=e.slice(r.start,r.end+1);let a=0,l=0,d=0,c=0;for(const g of s)g.oldLine!==null&&(a===0&&(a=g.oldLine),d++),g.newLine!==null&&(l===0&&(l=g.newLine),c++);const p=a||(d===0?0:1),f=l||(c===0?0:1);return{header:`@@ -${p},${d} +${f},${c} @@`,oldStart:p,oldCount:d,newStart:f,newCount:c,lines:s}})}function Ku(e){return JSON.stringify(Ys(e),null,2)}function Ys(e){if(Array.isArray(e))return e.map(Ys);if(e&&typeof e=="object"){const t=e,n={};for(const o of Object.keys(t).sort())n[o]=Ys(t[o]);return n}return e}const _$={display:"flex",flexDirection:"column",height:"100%",minHeight:0},x$={display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:"1px solid #e2e8f0",background:"#f8fafc"},w$={display:"inline-flex",alignItems:"center",gap:4,padding:"4px 10px",borderRadius:6,border:"1px solid #cbd5e1",background:"#ffffff",color:"#0f172a",fontSize:12,fontWeight:600,cursor:"pointer",outline:"none"},k$={marginLeft:"auto",border:"none",background:"transparent",fontSize:16,color:"#64748b",cursor:"pointer",outline:"none"},S$={display:"flex",alignItems:"center",gap:12,padding:"8px 16px",borderBottom:"1px solid #e2e8f0",background:"#ffffff",fontSize:12,fontWeight:600},C$={flex:1,minHeight:0,overflow:"auto",background:"#ffffff",fontFamily:'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',fontSize:12,lineHeight:1.5},Vu={padding:"24px 12px",textAlign:"center",color:"#94a3b8",fontSize:13},$$={padding:"2px 12px",background:"#f1f5f9",color:"#64748b",borderTop:"1px solid #e2e8f0",borderBottom:"1px solid #e2e8f0",userSelect:"none"};function E$(e){return e==="add"?{row:{background:"#e6ffed"},gutter:{background:"#cdffd8",color:"#22863a"},sign:"+",signColor:"#22863a"}:e==="remove"?{row:{background:"#ffeef0"},gutter:{background:"#ffdce0",color:"#b31d28"},sign:"-",signColor:"#b31d28"}:{row:{background:"#ffffff"},gutter:{background:"#f8fafc",color:"#94a3b8"},sign:" ",signColor:"#94a3b8"}}const Uu={display:"inline-block",width:44,textAlign:"right",padding:"0 6px",color:"#94a3b8",userSelect:"none",borderRight:"1px solid #e2e8f0",whiteSpace:"nowrap"};function I$({oldVersion:e,newVersion:t,oldContent:n,newContent:o,t:r,onBack:s,onClose:a}){const l=u.useMemo(()=>{const c=Ku(n),p=Ku(o);return m$(c,p)},[n,o]),d=l.addedCount<0;return i.jsxs("div",{className:"g7le-version-diff","data-testid":"g7le-version-diff",style:_$,children:[i.jsxs("div",{"data-modal-drag-handle":!0,style:x$,children:[i.jsxs("button",{type:"button",onClick:s,style:w$,"data-testid":"g7le-version-diff-back",children:["← ",r("layout_editor.version_history.diff_back")]}),i.jsx("span",{style:{fontSize:14,fontWeight:700},"data-testid":"g7le-version-diff-title",children:r("layout_editor.version_history.diff_title",{old:e,new:t})}),i.jsx("button",{type:"button","aria-label":r("layout_editor.version_history.close"),onClick:a,style:k$,"data-testid":"g7le-version-diff-close",children:"✕"})]}),!d&&i.jsxs("div",{style:S$,"data-testid":"g7le-version-diff-summary",children:[i.jsx("span",{style:{color:"#16a34a"},"data-testid":"g7le-version-diff-added",children:r("layout_editor.version_history.added",{count:Math.max(0,l.addedCount)})}),i.jsx("span",{style:{color:"#dc2626"},"data-testid":"g7le-version-diff-removed",children:r("layout_editor.version_history.removed",{count:Math.max(0,l.removedCount)})})]}),i.jsx("div",{style:C$,children:d?i.jsx("div",{style:Vu,"data-testid":"g7le-version-diff-too-large",children:r("layout_editor.version_history.diff_too_large")}):l.identical?i.jsx("div",{style:Vu,"data-testid":"g7le-version-diff-identical",children:r("layout_editor.version_history.diff_identical")}):l.hunks.map((c,p)=>i.jsxs("div",{"data-testid":`gLine truncated
|
||||
To pick up a draggable item, press the space bar.
|
||||
While dragging, use the arrow keys to move the item.
|
||||
Press space again to drop the item in its new position, or press escape to cancel.
|
||||
`},I7={onDragStart(e){let{active:t}=e;return"Picked up draggable item "+t.id+"."},onDragOver(e){let{active:t,over:n}=e;return n?"Draggable item "+t.id+" was moved over droppable area "+n.id+".":"Draggable item "+t.id+" is no longer over a droppable area."},onDragEnd(e){let{active:t,over:n}=e;return n?"Draggable item "+t.id+" was dropped over droppable area "+n.id:"Draggable item "+t.id+" was dropped."},onDragCancel(e){let{active:t}=e;return"Dragging was cancelled. Draggable item "+t.id+" was dropped."}};function A7(e){let{announcements:t=I7,container:n,hiddenTextDescribedById:o,screenReaderInstructions:r=E7}=e;const{announce:s,announcement:a}=S7(),l=Qr("DndLiveRegion"),[d,c]=u.useState(!1);if(u.useEffect(()=>{c(!0)},[]),C7(u.useMemo(()=>({onDragStart(f){let{active:y}=f;s(t.onDragStart({active:y}))},onDragMove(f){let{active:y,over:g}=f;t.onDragMove&&s(t.onDragMove({active:y,over:g}))},onDragOver(f){let{active:y,over:g}=f;s(t.onDragOver({active:y,over:g}))},onDragEnd(f){let{active:y,over:g}=f;s(t.onDragEnd({active:y,over:g}))},onDragCancel(f){let{active:y,over:g}=f;s(t.onDragCancel({active:y,over:g}))}}),[s,t])),!d)return null;const p=u.createElement(u.Fragment,null,u.createElement(w7,{id:o,value:r.draggable}),u.createElement(k7,{id:l,announcement:a}));return n?Sn.createPortal(p,n):p}var it;(function(e){e.DragStart="dragStart",e.DragMove="dragMove",e.DragEnd="dragEnd",e.DragCancel="dragCancel",e.DragOver="dragOver",e.RegisterDroppable="registerDroppable",e.SetDroppableDisabled="setDroppableDisabled",e.UnregisterDroppable="unregisterDroppable"})(it||(it={}));function ti(){}function T7(e,t){return u.useMemo(()=>({sensor:e,options:t??{}}),[e,t])}function D7(){for(var e=arguments.length,t=new Array(e),n=0;n<e;n++)t[n]=arguments[n];return u.useMemo(()=>[...t].filter(o=>o!=null),[...t])}const Ht=Object.freeze({x:0,y:0});function N7(e,t){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function z7(e,t){const n=ei(e);if(!n)return"0 0";const o={x:(n.x-t.left)/t.width*100,y:(n.y-t.top)/t.height*100};return o.x+"% "+o.y+"%"}function M7(e,t){let{data:{value:n}}=e,{data:{value:o}}=t;return n-o}function L7(e,t){let{data:{value:n}}=e,{data:{value:o}}=t;return o-n}function P7(e){let{left:t,top:n,height:o,width:r}=e;return[{x:t,y:n},{x:t+r,y:n},{x:t,y:n+o},{x:t+r,y:n+o}]}function O7(e,t){if(!e||e.length===0)return null;const[n]=e;return n[t]}function B7(e,t){const n=Math.max(t.top,e.top),o=Math.max(t.left,e.left),r=Math.min(t.left+t.width,e.left+e.width),s=Math.min(t.top+t.height,e.top+e.height),a=r-o,l=s-n;if(o<r&&n<s){const d=t.width*t.height,c=e.width*e.height,p=a*l,f=p/(d+c-p);return Number(f.toFixed(4))}return 0}const W7=e=>{let{collisionRect:t,droppableRects:n,droppableContainers:o}=e;const r=[];for(const s of o){const{id:a}=s,l=n.get(a);if(l){const d=B7(l,t);d>0&&r.push({id:a,data:{droppableContainer:s,value:d}})}}return r.sort(L7)};function j7(e,t){const{top:n,left:o,bottom:r,right:s}=t;return n<=e.y&&e.y<=r&&o<=e.x&&e.x<=s}const H7=e=>{let{droppableContainers:t,droppableRects:n,pointerCoordinates:o}=e;if(!o)return[];const r=[];for(const s of t){const{id:a}=s,l=n.get(a);if(l&&j7(o,l)){const c=P7(l).reduce((f,y)=>f+N7(o,y),0),p=Number((c/4).toFixed(4));r.push({id:a,data:{droppableContainer:s,value:p}})}}return r.sort(M7)};function F7(e,t,n){return{...e,scaleX:t&&n?t.width/n.width:1,scaleY:t&&n?t.height/n.height:1}}function Op(e,t){return e&&t?{x:e.left-t.left,y:e.top-t.top}:Ht}function K7(e){return function(n){for(var o=arguments.length,r=new Array(o>1?o-1:0),s=1;s<o;s++)r[s-1]=arguments[s];return r.reduce((a,l)=>({...a,top:a.top+e*l.y,bottom:a.bottom+e*l.y,left:a.left+e*l.x,right:a.right+e*l.x}),{...n})}}const V7=K7(1);function Bp(e){if(e.startsWith("matrix3d(")){const t=e.slice(9,-1).split(/, /);return{x:+t[12],y:+t[13],scaleX:+t[0],scaleY:+t[5]}}else if(e.startsWith("matrix(")){const t=e.slice(7,-1).split(/, /);return{x:+t[4],y:+t[5],scaleX:+t[0],scaleY:+t[3]}}return null}function U7(e,t,n){const o=Bp(t);if(!o)return e;const{scaleX:r,scaleY:s,x:a,y:l}=o,d=e.left-a-(1-r)*parseFloat(n),c=e.top-l-(1-s)*parseFloat(n.slice(n.indexOf(" ")+1)),p=r?e.width/r:e.width,f=s?e.height/s:e.height;return{width:p,height:f,top:c,right:d+p,bottom:c+f,left:d}}const q7={ignoreTransform:!1};function Bo(e,t){t===void 0&&(t=q7);let n=e.getBoundingClientRect();if(t.ignoreTransform){const{transform:c,transformOrigin:p}=_t(e).getComputedStyle(e);c&&(n=U7(n,c,p))}const{top:o,left:r,width:s,height:a,bottom:l,right:d}=n;return{top:o,left:r,width:s,height:a,bottom:l,right:d}}function Wp(e){return Bo(e,{ignoreTransform:!0})}function G7(e){const t=e.innerWidth,n=e.innerHeight;return{top:0,left:0,right:t,bottom:n,width:t,height:n}}function Y7(e,t){return t===void 0&&(t=_t(e).getComputedStyle(e)),t.position==="fixed"}function X7(e,t){t===void 0&&(t=_t(e).getComputedStyle(e));const n=/(auto|scroll|overlay)/;return["overflow","overflowX","overflowY"].some(r=>{const s=t[r];return typeof s=="string"?n.test(s):!1})}function aa(e,t){const n=[];
Line truncated
|
||||
`:"",e.message]})]}),i.jsxs("div",{style:{display:"flex",justifyContent:"center",gap:8,flexWrap:"wrap"},children:[e.kind==="unauthorized"&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-signin",onClick:a,style:Ha,children:t("layout_editor.access_error.action.sign_in")}),e.kind==="forbidden"&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-home",onClick:r,style:Ha,children:t("layout_editor.access_error.action.go_home")}),(e.kind==="not_found"||e.kind==="server_error"||e.kind==="network"||e.kind==="unknown")&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-retry",onClick:s,style:Ha,children:t("layout_editor.access_error.action.retry")}),e.kind!=="unauthorized"&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-home-secondary",onClick:r,style:h4,children:t("layout_editor.access_error.action.go_admin_home")})]})]})]})}const Ha={padding:"8px 16px",background:"#2563eb",color:"#fff",border:"1px solid #1d4ed8",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},h4={padding:"8px 16px",background:"#fff",color:"#475569",border:"1px solid #cbd5e1",borderRadius:6,fontSize:13,fontWeight:500,cursor:"pointer"};function y4(e){const t=window;if(typeof t>"u")return{getSnapshot:()=>({globalState:{},localState:{}}),restore:()=>{}};const n=t.G7Core||{},o={templateApp:t.__templateApp,g7CoreState:n.state,g7CoreDispatch:n.dispatch,g7CoreData:n.data,g7CoreModal:n.modal,g7CoreToast:n.toast};let r={...e.initialGlobalState??{}};r._local||(r._local={});const s=[],a=()=>{const c={globalState:{...r},localState:{...r._local??{}}};for(const p of s)try{p(r)}catch{}e.onChange?.(c)},l={},d={getGlobalState:()=>r,setGlobalState:(c,p)=>{typeof c=="function"?r=c(r):r={...r,...c},a()},onGlobalStateChange:c=>(s.push(c),()=>{const p=s.indexOf(c);p>=0&&s.splice(p,1)}),get globalState(){return r},getActionDispatcher:()=>e.actionDispatcher,getDataSource:c=>l[c]??null,setDataSource:(c,p)=>{l[c]=p},refetchDataSource:async c=>null,updateDataSourceItem:(c,p,f)=>l[c]??null,getLocale:()=>e.locale,changeLocale:c=>{},getRouter:()=>({navigate:()=>{},replace:()=>{},push:()=>{}}),updateQueryParams:()=>{}};return t.__templateApp=d,n&&typeof n.dispatch=="function"&&(n.dispatch=(c,p)=>{try{return e.actionDispatcher.executeAction(c,p??{})}catch{return}}),n&&typeof n.modal=="object"&&n.modal!==null&&(n.modal={...n.modal,open:()=>{},close:()=>{},closeAll:()=>{}}),n&&typeof n.toast=="object"&&n.toast!==null&&(n.toast={...n.toast,push:()=>{},dismiss:()=>{}}),{getSnapshot:()=>({globalState:{...r},localState:{...r._local??{}}}),restore:()=>{t.__templateApp===d&&(t.__templateApp=o.templateApp),t.G7Core&&(o.g7CoreState!==void 0&&(t.G7Core.state=o.g7CoreState),o.g7CoreDispatch!==void 0&&(t.G7Core.dispatch=o.g7CoreDispatch),o.g7CoreData!==void 0&&(t.G7Core.data=o.g7CoreData),o.g7CoreModal!==void 0&&(t.G7Core.modal=o.g7CoreModal),o.g7CoreToast!==void 0&&(t.G7Core.toast=o.g7CoreToast)),s.length=0}}}const b4=new Set(["id","userId","postId","orderId","productId","categoryId","commentId","noticeId","inquiryId","addressId","wishlistId","notificationId","roleId","menuId"]);function m4(e){if(!e)return{};const t={},n=/:([a-zA-Z_][a-zA-Z0-9_]*)/g;let o;for(;(o=n.exec(e))!==null;){const r=o[1];t[r]=v4(r)}return t}function v4(e){return b4.has(e)||/Id$|_id$/.test(e)?"1":"sample"}const _4=ar("LayoutEditor");function Go(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function hi(e){if(Array.isArray(e))return e.map(t=>hi(t));if(Go(e)){const t={};for(const[n,o]of Object.entries(e))t[n]=hi(o);return t}return e}function pg(e,t="",n=new Set){for(const[o,r]of Object.entries(e)){const s=t?`${t}.${o}`:o;Go(r)?pg(r,s,n):n.add(s)}return n}function fg(e,t,n,o,r,s=""){for(const[a,l]of Object.entries(t)){const d=s?`${s}.${a}`:a;if(n.has(d)){r(`[LayoutEditor] sampleGlobal key conflict: extension '${o}' override of core key '${d}' ignored`);continue}const c=e[a];Go(l)&&Go(c)?fg(c,l,n,o,r,d):e[a]=hi(l)}}function Fa(e){const t=e.warn??(r=>_4.warn(r)),n=hi(e.coreSeed??{});e.isGuestOnly&&"currentUser"in n&&delete n.currentUser;const o=pg(n);for(const r of e.sources)Go(r.sampleGlobal)&&fg(n,r.sampleGlobal,o,r.id,t);return e.isGuestOnly&&"currentUser"in n&&delete n.currentUser,n}function x4(e){const t={};if(!e||typeof e!="object"||Array.isArray(e))return t;for(const[n,o]of Object.entries(e))n.startsWith("$")&&(t[n]=o);return t}function w4(e){const t={};if(!e||typeof e!="object"||Array.isArray(e))return t;for(const[n,o]of Object.entries(e))n.startsWith("$")||(t[n]=o);return t}function k4(e){if(typeof e!="string")return null;const t=/^\s*\{\{\s*([\s\S]*?)\s*\}\}\s*$/.exec(e);if(!t)return null;let n=t[1];const o=n.indexOf("??");return o>=0&&(n=n.slice(0,o)),n=n.replace(/\?\./g,".").replace(/\s+/g,""),n===""||!/^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(n)?null:n.split(".")}function S4(e,t){const n=k4(t);if(!n||n.length===0)return e;let o=e;for(let a=0;a<n.length;a++){if(o==null||typeof o!="obj
Line truncated
|
||||
//# sourceMappingURL=layout-editor.min.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"layout-editor.min.js","sources":["../../../resources/js/core/template-engine/layout-editor/__runtime-shims/runtime.ts","../../../resources/js/core/template-engine/layout-editor/__runtime-shims/TranslationContext.ts","../../../resources/js/core/template-engine/layout-editor/devtools/editorTrackers.ts","../../../resources/js/core/template-engine/layout-editor/LayoutEditorContext.tsx","../../../resources/js/core/template-engine/layout-editor/LayoutDocumentContext.tsx","../../../resources/js/core/template-engine/layout-editor/EditorModalContext.tsx","../../../resources/js/core/template-engine/layout-editor/components/EditorToolbar.tsx","../../../resources/js/core/template-engine/layout-editor/utils/authToken.ts","../../../resources/js/core/template-engine/layout-editor/utils/layoutAttachments.ts","../../../resources/js/core/template-engine/layout-editor/components/property-controls/LayoutAttachmentManager.tsx","../../../resources/js/core/template-engine/layout-editor/utils/customTranslations.ts","../../../resources/js/core/template-engine/layout-editor/__runtime-shims/TranslationEngine.ts","../../../resources/js/core/template-engine/layout-editor/__runtime-shims/Logger.ts","../../../resources/js/core/template-engine/layout-editor/spec/editorSpecLoader.ts","../../../resources/js/core/template-engine/layout-editor/spec/bindingCandidates.ts","../../../resources/js/core/template-engine/layout-editor/spec/inlineBindingUtils.ts","../../../resources/js/core/template-engine/layout-editor/utils/layoutTreeUtils.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useInlineEdit.ts","../../../resources/js/core/template-engine/layout-editor/hooks/pendingCustomTranslations.ts","../../../resources/js/core/template-engine/layout-editor/components/LocaleSwitcher.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/PlaceholderChipInput.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/CustomTranslationManager.tsx","../../../resources/js/core/template-engine/layout-editor/hooks/usePageSettings.ts","../../../resources/js/core/template-engine/layout-editor/sample-data/sampleDataProvider.ts","../../../resources/js/core/template-engine/layout-editor/__runtime-shims/DataBindingEngine.ts","../../../resources/js/core/template-engine/layout-editor/spec/computedRecipeEngine.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useBindingCandidates.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useSeoBindingCandidates.ts","../../../resources/js/core/template-engine/layout-editor/spec/candidatePools.ts","../../../resources/js/core/template-engine/layout-editor/spec/actionRecipeEngine.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useCustomTranslation.ts","../../../resources/js/core/template-engine/layout-editor/components/property-controls/inlineBindingApi.ts","../../../resources/js/core/template-engine/layout-editor/components/property-controls/TranslationField.tsx","../../../resources/js/core/template-engine/layout-editor/components/shared/FloatingDropdown.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/InlineBindingScalarPicker.tsx","../../../resources/js/core/template-engine/layout-editor/spec/expressionValueTree.ts","../../../resources/js/core/template-engine/layout-editor/components/DropLine.tsx","../../../resources/js/core/template-engine/layout-editor/hooks/useListDragReorder.ts","../../../resources/js/core/template-engine/layout-editor/components/page-settings/SegmentedValueEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/ConditionalValueEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/I18nTextField.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/TagInputControl.tsx","../../../resources/js/core/template-engine/layout-editor/spec/widgetRegistry.ts","../../../resources/js/core/template-engine/layout-editor/components/page-settings/MetaForm.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/FormPrimitives.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/BindingChipTextInput.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/DataChipValueInput.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/KeyValueChipEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/SeoOgForm.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/SeoStructuredDataEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/SeoVarsEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/SeoBotPreviewPanel.tsx","../../..Line truncated
|
||||
+96
-1620
File diff suppressed because it is too large.
Load diff
@@ -1 +1 @@
|
||||
{"version":3,"file":"template-engine.min.js","sources":["../../../node_modules/react/cjs/react.production.js","../../../node_modules/react/index.js","../../../node_modules/scheduler/cjs/scheduler.production.js","../../../node_modules/scheduler/index.js","../../../node_modules/react-dom/cjs/react-dom.production.js","../../../node_modules/react-dom/index.js","../../../node_modules/react-dom/cjs/react-dom-client.production.js","../../../node_modules/react-dom/client.js","../../../resources/js/core/utils/Logger.ts","../../../resources/js/core/template-engine/ComponentRegistry.ts","../../../resources/js/core/template-engine/TranslationEngine.ts","../../../resources/js/core/template-engine/PipeRegistry.ts","../../../resources/js/core/template-engine/rawMarkers.ts","../../../resources/js/core/template-engine/DataBindingEngine.ts","../../../resources/js/core/template-engine/ResponsiveManager.ts","../../../resources/js/core/template-engine/helpers/ConditionEvaluator.ts","../../../resources/js/core/template-engine/helpers/RenderHelpers.ts","../../../node_modules/axios/lib/helpers/bind.js","../../../node_modules/axios/lib/utils.js","../../../node_modules/axios/lib/helpers/parseHeaders.js","../../../node_modules/axios/lib/helpers/sanitizeHeaderValue.js","../../../node_modules/axios/lib/core/AxiosHeaders.js","../../../node_modules/axios/lib/core/AxiosError.js","../../../node_modules/axios/lib/helpers/null.js","../../../node_modules/axios/lib/helpers/toFormData.js","../../../node_modules/axios/lib/helpers/AxiosURLSearchParams.js","../../../node_modules/axios/lib/helpers/buildURL.js","../../../node_modules/axios/lib/core/InterceptorManager.js","../../../node_modules/axios/lib/defaults/transitional.js","../../../node_modules/axios/lib/platform/browser/index.js","../../../node_modules/axios/lib/platform/browser/classes/URLSearchParams.js","../../../node_modules/axios/lib/platform/browser/classes/FormData.js","../../../node_modules/axios/lib/platform/browser/classes/Blob.js","../../../node_modules/axios/lib/platform/common/utils.js","../../../node_modules/axios/lib/platform/index.js","../../../node_modules/axios/lib/helpers/toURLEncodedForm.js","../../../node_modules/axios/lib/helpers/formDataToJSON.js","../../../node_modules/axios/lib/defaults/index.js","../../../node_modules/axios/lib/core/transformData.js","../../../node_modules/axios/lib/cancel/isCancel.js","../../../node_modules/axios/lib/cancel/CanceledError.js","../../../node_modules/axios/lib/core/settle.js","../../../node_modules/axios/lib/helpers/parseProtocol.js","../../../node_modules/axios/lib/helpers/speedometer.js","../../../node_modules/axios/lib/helpers/throttle.js","../../../node_modules/axios/lib/helpers/progressEventReducer.js","../../../node_modules/axios/lib/helpers/isURLSameOrigin.js","../../../node_modules/axios/lib/helpers/cookies.js","../../../node_modules/axios/lib/helpers/isAbsoluteURL.js","../../../node_modules/axios/lib/helpers/combineURLs.js","../../../node_modules/axios/lib/core/buildFullPath.js","../../../node_modules/axios/lib/core/mergeConfig.js","../../../node_modules/axios/lib/helpers/resolveConfig.js","../../../node_modules/axios/lib/adapters/xhr.js","../../../node_modules/axios/lib/helpers/composeSignals.js","../../../node_modules/axios/lib/helpers/trackStream.js","../../../node_modules/axios/lib/helpers/estimateDataURLDecodedBytes.js","../../../node_modules/axios/lib/env/data.js","../../../node_modules/axios/lib/adapters/fetch.js","../../../node_modules/axios/lib/adapters/adapters.js","../../../node_modules/axios/lib/core/dispatchRequest.js","../../../node_modules/axios/lib/helpers/validator.js","../../../node_modules/axios/lib/core/Axios.js","../../../node_modules/axios/lib/cancel/CancelToken.js","../../../node_modules/axios/lib/helpers/spread.js","../../../node_modules/axios/lib/helpers/isAxiosError.js","../../../node_modules/axios/lib/helpers/HttpStatusCode.js","../../../node_modules/axios/lib/axios.js","../../../node_modules/axios/index.js","../../../resources/js/core/identity/types.ts","../../../resources/js/core/identity/IdentityGuardInterceptor.ts","../../../resources/js/core/api/ApiClient.ts","../../../resources/js/core/auth/AuthManager.ts","../../../resources/js/core/types/ErrorHandling.ts","../../../resources/js/core/error/ErrorHandlingResolver.ts","../../../node_modules/react/cjs/react-jsx-runtime.production.js","../../../node_modules/react/jsx-runtime.js","../../../resources/js/core/template-engine/ParentContextProvider.tsx","../../../resources/js/core/template-engine/ActionDispatcher.ts","../../../resources/js/core/template-engine/TransitionManager.ts","../../../resources/js/core/template-engine/TransitionContext.tsx","../../../resources/js/core/template-engine/ResponsiveContext.tsx","../../../resources/js/core/hooks/useControllableState.ts","../../../resources/js/core/template-engine/FormContext.tsx","../../../resources/js/core/template-engine/IsolatedStateContext.tsx","../../../node_modules/@dnd-kit/utilities/dist/utiliLine truncated
|
||||
{"version":3,"file":"template-engine.min.js","sources":["../../../node_modules/react/cjs/react.production.js","../../../node_modules/react/index.js","../../../node_modules/scheduler/cjs/scheduler.production.js","../../../node_modules/scheduler/index.js","../../../node_modules/react-dom/cjs/react-dom.production.js","../../../node_modules/react-dom/index.js","../../../node_modules/react-dom/cjs/react-dom-client.production.js","../../../node_modules/react-dom/client.js","../../../resources/js/core/utils/Logger.ts","../../../resources/js/core/template-engine/ComponentRegistry.ts","../../../resources/js/core/template-engine/TranslationEngine.ts","../../../resources/js/core/template-engine/PipeRegistry.ts","../../../resources/js/core/template-engine/rawMarkers.ts","../../../resources/js/core/template-engine/DataBindingEngine.ts","../../../resources/js/core/template-engine/ResponsiveManager.ts","../../../resources/js/core/template-engine/helpers/ConditionEvaluator.ts","../../../resources/js/core/template-engine/helpers/RenderHelpers.ts","../../../node_modules/axios/lib/helpers/bind.js","../../../node_modules/axios/lib/utils.js","../../../node_modules/axios/lib/core/AxiosError.js","../../../node_modules/axios/lib/helpers/null.js","../../../node_modules/axios/lib/helpers/toFormData.js","../../../node_modules/axios/lib/helpers/AxiosURLSearchParams.js","../../../node_modules/axios/lib/helpers/buildURL.js","../../../node_modules/axios/lib/core/InterceptorManager.js","../../../node_modules/axios/lib/defaults/transitional.js","../../../node_modules/axios/lib/platform/browser/index.js","../../../node_modules/axios/lib/platform/browser/classes/URLSearchParams.js","../../../node_modules/axios/lib/platform/browser/classes/FormData.js","../../../node_modules/axios/lib/platform/browser/classes/Blob.js","../../../node_modules/axios/lib/platform/common/utils.js","../../../node_modules/axios/lib/platform/index.js","../../../node_modules/axios/lib/helpers/toURLEncodedForm.js","../../../node_modules/axios/lib/helpers/formDataToJSON.js","../../../node_modules/axios/lib/defaults/index.js","../../../node_modules/axios/lib/helpers/parseHeaders.js","../../../node_modules/axios/lib/core/AxiosHeaders.js","../../../node_modules/axios/lib/core/transformData.js","../../../node_modules/axios/lib/cancel/isCancel.js","../../../node_modules/axios/lib/cancel/CanceledError.js","../../../node_modules/axios/lib/core/settle.js","../../../node_modules/axios/lib/helpers/parseProtocol.js","../../../node_modules/axios/lib/helpers/speedometer.js","../../../node_modules/axios/lib/helpers/throttle.js","../../../node_modules/axios/lib/helpers/progressEventReducer.js","../../../node_modules/axios/lib/helpers/isURLSameOrigin.js","../../../node_modules/axios/lib/helpers/cookies.js","../../../node_modules/axios/lib/helpers/isAbsoluteURL.js","../../../node_modules/axios/lib/helpers/combineURLs.js","../../../node_modules/axios/lib/core/buildFullPath.js","../../../node_modules/axios/lib/core/mergeConfig.js","../../../node_modules/axios/lib/helpers/resolveConfig.js","../../../node_modules/axios/lib/adapters/xhr.js","../../../node_modules/axios/lib/helpers/composeSignals.js","../../../node_modules/axios/lib/helpers/trackStream.js","../../../node_modules/axios/lib/adapters/fetch.js","../../../node_modules/axios/lib/adapters/adapters.js","../../../node_modules/axios/lib/core/dispatchRequest.js","../../../node_modules/axios/lib/env/data.js","../../../node_modules/axios/lib/helpers/validator.js","../../../node_modules/axios/lib/core/Axios.js","../../../node_modules/axios/lib/cancel/CancelToken.js","../../../node_modules/axios/lib/helpers/spread.js","../../../node_modules/axios/lib/helpers/isAxiosError.js","../../../node_modules/axios/lib/helpers/HttpStatusCode.js","../../../node_modules/axios/lib/axios.js","../../../node_modules/axios/index.js","../../../resources/js/core/identity/types.ts","../../../resources/js/core/identity/IdentityGuardInterceptor.ts","../../../resources/js/core/api/ApiClient.ts","../../../resources/js/core/auth/AuthManager.ts","../../../resources/js/core/types/ErrorHandling.ts","../../../resources/js/core/error/ErrorHandlingResolver.ts","../../../node_modules/react/cjs/react-jsx-runtime.production.js","../../../node_modules/react/jsx-runtime.js","../../../resources/js/core/template-engine/ParentContextProvider.tsx","../../../resources/js/core/template-engine/ActionDispatcher.ts","../../../resources/js/core/template-engine/TransitionManager.ts","../../../resources/js/core/template-engine/TransitionContext.tsx","../../../resources/js/core/template-engine/ResponsiveContext.tsx","../../../resources/js/core/hooks/useControllableState.ts","../../../resources/js/core/template-engine/FormContext.tsx","../../../resources/js/core/template-engine/IsolatedStateContext.tsx","../../../node_modules/@dnd-kit/utilities/dist/utilities.esm.js","../../../node_modules/@dnd-kit/accessibility/dist/accessibility.esm.js","../../../node_modules/@dnd-kit/core/dist/core.esm.jLine truncated
|
||||
@@ -28,6 +28,23 @@ vi.mock('../template-engine/ActionDispatcher', () => ({
|
||||
|
||||
vi.mock('../template-engine/DataBindingEngine', () => ({
|
||||
DataBindingEngine: vi.fn(),
|
||||
// @since engine-v1.51.0 __runtime 노출용 싱글톤 인스턴스
|
||||
dataBindingEngine: {},
|
||||
}));
|
||||
|
||||
// @since engine-v1.51.0 __runtime(편집기 lazy 번들 공유) 노출용 모듈 mock
|
||||
vi.mock('../template-engine/DataSourceManager', () => ({
|
||||
DataSourceManager: vi.fn(),
|
||||
dataSourceManager: {},
|
||||
}));
|
||||
|
||||
vi.mock('../template-engine/DynamicRenderer', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../template-engine/ResponsiveManager', () => ({
|
||||
responsiveManager: {},
|
||||
BREAKPOINT_PRESETS: {},
|
||||
}));
|
||||
|
||||
vi.mock('../template-engine/TransitionContext', () => ({
|
||||
@@ -36,10 +53,16 @@ vi.mock('../template-engine/TransitionContext', () => ({
|
||||
|
||||
vi.mock('../template-engine/TranslationContext', () => ({
|
||||
useTranslation: vi.fn(() => ({ t: (key: string) => key })),
|
||||
// @since engine-v1.51.0 __runtime 노출용
|
||||
TranslationProvider: () => null,
|
||||
TranslationReactContext: {},
|
||||
}));
|
||||
|
||||
vi.mock('../template-engine/ResponsiveContext', () => ({
|
||||
useResponsive: vi.fn(() => ({ width: 1024, isMobile: false })),
|
||||
// @since engine-v1.51.0 __runtime 노출용
|
||||
ResponsiveProvider: () => null,
|
||||
ResponsiveContext: {},
|
||||
}));
|
||||
|
||||
vi.mock('../auth/AuthManager', () => ({
|
||||
@@ -148,6 +171,52 @@ describe('G7CoreGlobals - initializeG7CoreGlobals()', () => {
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// @since engine-v1.51.0 — 편집기 lazy 번들 공유용 코어 런타임 표면
|
||||
it('window.G7Core.__runtime 에 코어 런타임 표면을 노출해야 함', () => {
|
||||
const deps = createMockDependencies();
|
||||
initializeG7CoreGlobals(deps);
|
||||
|
||||
const runtime = (window as any).G7Core.__runtime;
|
||||
expect(runtime).toBeDefined();
|
||||
|
||||
// 편집기 lazy 번들이 shim 으로 빌려 쓰는 동일성 민감 멤버 전수 노출 확인
|
||||
const requiredKeys = [
|
||||
'DynamicRenderer',
|
||||
'ComponentRegistry',
|
||||
'TranslationEngine',
|
||||
'DataSourceManager',
|
||||
'dataSourceManager',
|
||||
'DataBindingEngine',
|
||||
'dataBindingEngine',
|
||||
'ActionDispatcher',
|
||||
'TranslationReactContext',
|
||||
'TranslationProvider',
|
||||
'useTranslation',
|
||||
'ResponsiveContext',
|
||||
'ResponsiveProvider',
|
||||
'useResponsive',
|
||||
'responsiveManager',
|
||||
'BREAKPOINT_PRESETS',
|
||||
'AuthManager',
|
||||
'createLogger',
|
||||
];
|
||||
for (const key of requiredKeys) {
|
||||
expect(runtime[key], `__runtime.${key} 미노출`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('__runtime 을 중복 초기화해도 멱등해야 함(동일 참조 유지)', () => {
|
||||
const deps = createMockDependencies();
|
||||
initializeG7CoreGlobals(deps);
|
||||
const first = (window as any).G7Core.__runtime;
|
||||
|
||||
// 재초기화 시 기존 __runtime 참조 보존 (편집기 먼저 로드 시나리오 대비)
|
||||
initializeG7CoreGlobals(deps);
|
||||
const second = (window as any).G7Core.__runtime;
|
||||
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('G7CoreGlobals - componentEvent API', () => {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* loadDevToolsBundle 테스트 — DevTools lazy 번들 로더 (engine-v1.52.0)
|
||||
*
|
||||
* DevTools 디버그 전용 모듈(패널/진단엔진/서버커넥터/스타일추적기)은 별도 번들
|
||||
* (devtools.min.js)로 분리되어 디버그 모드에서만 <script> 주입으로 로드된다. 본 테스트는
|
||||
* 로더 계약을 검증한다:
|
||||
* - 이미 로드됨(__devtools 존재) → 즉시 반환, <script> 미주입 (멱등)
|
||||
* - 미로드 → <script> 1회 주입, load 이벤트 후 모듈 묶음 반환
|
||||
* - 동시 다중 호출 → in-flight promise 병합
|
||||
* - 로드 실패(error) → reject
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
describe('loadDevToolsBundle — DevTools lazy 번들 로더', () => {
|
||||
let appendedScripts: HTMLScriptElement[];
|
||||
let originalAppendChild: typeof document.head.appendChild;
|
||||
let loadDevToolsBundle: () => Promise<any>;
|
||||
|
||||
const fakeBundle = {
|
||||
DiagnosticEngine: class {},
|
||||
getServerConnector: () => ({}),
|
||||
getStyleTracker: () => ({}),
|
||||
DevToolsPanel: () => null,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
appendedScripts = [];
|
||||
(window as any).G7Core = {};
|
||||
(window as any).G7Config = { coreDevToolsAsset: '/build/core/devtools.min.js?v=1' };
|
||||
|
||||
document.getElementById('g7-devtools-bundle')?.remove();
|
||||
|
||||
originalAppendChild = document.head.appendChild.bind(document.head);
|
||||
vi.spyOn(document.head, 'appendChild').mockImplementation((node: any) => {
|
||||
if (node.tagName === 'SCRIPT') {
|
||||
appendedScripts.push(node);
|
||||
return node;
|
||||
}
|
||||
return originalAppendChild(node);
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
({ loadDevToolsBundle } = await import('../template-engine/G7CoreGlobals'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.getElementById('g7-devtools-bundle')?.remove();
|
||||
delete (window as any).G7Core;
|
||||
delete (window as any).G7Config;
|
||||
});
|
||||
|
||||
it('이미 로드됨 → 즉시 반환하고 <script> 를 주입하지 않는다', async () => {
|
||||
(window as any).G7Core.__devtools = fakeBundle;
|
||||
|
||||
const result = await loadDevToolsBundle();
|
||||
|
||||
expect(result).toBe(fakeBundle);
|
||||
expect(appendedScripts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('미로드 → <script> 1회 주입 후 load 시 모듈 묶음 반환', async () => {
|
||||
const promise = loadDevToolsBundle();
|
||||
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
const script = appendedScripts[0];
|
||||
expect(script.id).toBe('g7-devtools-bundle');
|
||||
expect(script.src).toContain('devtools.min.js');
|
||||
expect(script.async).toBe(false);
|
||||
|
||||
(window as any).G7Core.__devtools = fakeBundle;
|
||||
script.dispatchEvent(new Event('load'));
|
||||
|
||||
await expect(promise).resolves.toBe(fakeBundle);
|
||||
});
|
||||
|
||||
it('동시 다중 호출 → <script> 1회만 주입', async () => {
|
||||
const p1 = loadDevToolsBundle();
|
||||
const p2 = loadDevToolsBundle();
|
||||
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
|
||||
(window as any).G7Core.__devtools = fakeBundle;
|
||||
appendedScripts[0].dispatchEvent(new Event('load'));
|
||||
|
||||
const [r1, r2] = await Promise.all([p1, p2]);
|
||||
expect(r1).toBe(fakeBundle);
|
||||
expect(r2).toBe(fakeBundle);
|
||||
});
|
||||
|
||||
it('로드 실패(error) → reject 한다', async () => {
|
||||
const promise = loadDevToolsBundle();
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
|
||||
appendedScripts[0].dispatchEvent(new Event('error'));
|
||||
|
||||
await expect(promise).rejects.toThrow(/DevTools 번들 로드 실패/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* loadLayoutEditorBundle 테스트 — 편집기 lazy 번들 로더 (engine-v1.51.0)
|
||||
*
|
||||
* 편집기는 별도 번들(layout-editor.min.js)로 분리되어 `/admin/layout-editor/*` 진입 시에만
|
||||
* <script> 주입으로 로드된다. 본 테스트는 로더의 계약을 검증한다:
|
||||
* - 이미 로드됨(__LayoutEditorChrome 존재) → 즉시 반환, <script> 미주입 (멱등)
|
||||
* - 미로드 → <script> 1회 주입, load 이벤트 후 컴포넌트 반환
|
||||
* - 동시 다중 호출 → in-flight promise 병합 (중복 주입 없음)
|
||||
* - 로드 실패(error) → reject + 재시도 가능
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
describe('loadLayoutEditorBundle — 편집기 lazy 번들 로더', () => {
|
||||
let appendedScripts: HTMLScriptElement[];
|
||||
let originalAppendChild: typeof document.head.appendChild;
|
||||
// 모듈 레벨 in-flight promise 가 테스트 간 잔존하지 않도록 매 테스트 모듈 재로드
|
||||
let loadLayoutEditorBundle: () => Promise<any>;
|
||||
|
||||
beforeEach(async () => {
|
||||
appendedScripts = [];
|
||||
(window as any).G7Core = {};
|
||||
(window as any).G7Config = { coreEditorAsset: '/build/core/layout-editor.min.js?v=123' };
|
||||
|
||||
// 기존 주입 스크립트 제거
|
||||
document.getElementById('g7-layout-editor-bundle')?.remove();
|
||||
|
||||
// head.appendChild 를 가로채 주입된 <script> 를 기록 (실제 네트워크 로드 없음)
|
||||
originalAppendChild = document.head.appendChild.bind(document.head);
|
||||
vi.spyOn(document.head, 'appendChild').mockImplementation((node: any) => {
|
||||
if (node.tagName === 'SCRIPT') {
|
||||
appendedScripts.push(node);
|
||||
return node;
|
||||
}
|
||||
return originalAppendChild(node);
|
||||
});
|
||||
|
||||
// 모듈 재로드로 layoutEditorLoadPromise 초기화 (테스트 격리)
|
||||
vi.resetModules();
|
||||
({ loadLayoutEditorBundle } = await import('../template-engine'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.getElementById('g7-layout-editor-bundle')?.remove();
|
||||
delete (window as any).G7Core;
|
||||
delete (window as any).G7Config;
|
||||
});
|
||||
|
||||
it('이미 로드됨 → 즉시 반환하고 <script> 를 주입하지 않는다', async () => {
|
||||
const fakeChrome = () => null;
|
||||
(window as any).G7Core.__LayoutEditorChrome = fakeChrome;
|
||||
|
||||
const result = await loadLayoutEditorBundle();
|
||||
|
||||
expect(result).toBe(fakeChrome);
|
||||
expect(appendedScripts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('미로드 → <script> 를 1회 주입하고 load 후 컴포넌트를 반환한다', async () => {
|
||||
const fakeChrome = () => null;
|
||||
const promise = loadLayoutEditorBundle();
|
||||
|
||||
// 정확히 1개 주입
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
const script = appendedScripts[0];
|
||||
expect(script.id).toBe('g7-layout-editor-bundle');
|
||||
expect(script.src).toContain('layout-editor.min.js');
|
||||
expect(script.async).toBe(false);
|
||||
|
||||
// 편집기 번들 로드 시뮬레이션: 컴포넌트 노출 + load 이벤트
|
||||
(window as any).G7Core.__LayoutEditorChrome = fakeChrome;
|
||||
script.dispatchEvent(new Event('load'));
|
||||
|
||||
await expect(promise).resolves.toBe(fakeChrome);
|
||||
});
|
||||
|
||||
it('동시 다중 호출 → in-flight promise 로 병합되어 <script> 는 1회만 주입된다', async () => {
|
||||
const fakeChrome = () => null;
|
||||
|
||||
const p1 = loadLayoutEditorBundle();
|
||||
const p2 = loadLayoutEditorBundle();
|
||||
const p3 = loadLayoutEditorBundle();
|
||||
|
||||
// 세 호출이 하나의 주입으로 병합
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
|
||||
(window as any).G7Core.__LayoutEditorChrome = fakeChrome;
|
||||
appendedScripts[0].dispatchEvent(new Event('load'));
|
||||
|
||||
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
|
||||
expect(r1).toBe(fakeChrome);
|
||||
expect(r2).toBe(fakeChrome);
|
||||
expect(r3).toBe(fakeChrome);
|
||||
});
|
||||
|
||||
it('로드 실패(error) → reject 한다', async () => {
|
||||
const promise = loadLayoutEditorBundle();
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
|
||||
// 네트워크 실패 시뮬레이션
|
||||
appendedScripts[0].dispatchEvent(new Event('error'));
|
||||
|
||||
await expect(promise).rejects.toThrow(/편집기 번들 로드 실패/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* devtools-entry.ts — DevTools lazy 번들 진입점
|
||||
*
|
||||
* @since engine-v1.52.0
|
||||
*
|
||||
* 개발자 진단도구(DevTools)는 디버그 모드(`G7Config.debug` / 환경설정)를 켰을 때만 쓰인다.
|
||||
* 그럼에도 메인 코어 번들(`template-engine.min.js`)에 정적 포함되면 모든 사용자가 받고도
|
||||
* 실행하지 않는 죽은 코드가 된다(패널 UI 만 311KB). 이를 별도 `devtools.min.js` 로 분리해
|
||||
* `initDevToolsAPI()` 가 `isEnabled()` 참일 때만 런타임 `<script>` 주입으로 로드한다.
|
||||
*
|
||||
* 노출: 디버그 전용 무거운 모듈 4종을 `window.G7Core.__devtools` 로 노출.
|
||||
* - DiagnosticEngine (진단 엔진 클래스)
|
||||
* - getServerConnector (서버 커넥터 팩토리)
|
||||
* - getStyleTracker (스타일 추적기 팩토리)
|
||||
* - DevToolsPanel (패널 UI React 컴포넌트)
|
||||
*
|
||||
* `G7DevToolsCore`(추적 코어, 디버그 무관 항상 필요)는 메인 번들에 남아 `window.G7Core.__runtime`
|
||||
* 으로 공유되며, DevToolsPanel 등은 vite.config.devtools.js alias 로 그것을 빌려 쓴다.
|
||||
* React 는 external 로 window.React 재사용.
|
||||
*/
|
||||
|
||||
import { DiagnosticEngine } from './devtools/DiagnosticEngine';
|
||||
import { getServerConnector } from './devtools/ServerConnector';
|
||||
import { getStyleTracker } from './devtools/StyleTracker';
|
||||
import { DevToolsPanel } from './devtools/ui/DevToolsPanel';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const G7Core = (window as any).G7Core || ((window as any).G7Core = {});
|
||||
|
||||
G7Core.__devtools = {
|
||||
DiagnosticEngine,
|
||||
getServerConnector,
|
||||
getStyleTracker,
|
||||
DevToolsPanel,
|
||||
};
|
||||
|
||||
// 준비 완료 통지 — loadDevToolsBundle() 이 주입 전에 등록한 resolve 호출
|
||||
if (typeof G7Core.__onDevToolsReady === 'function') {
|
||||
try {
|
||||
G7Core.__onDevToolsReady();
|
||||
} catch {
|
||||
/* 콜백 실패 격리 — onload fallback 이 별도 resolve */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { DiagnosticEngine, getServerConnector, getStyleTracker, DevToolsPanel };
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* G7DevToolsCore 런타임 shim — DevTools lazy 번들 전용 (vite.config.devtools.js alias 대상)
|
||||
* @since engine-v1.52.0
|
||||
*
|
||||
* DevTools 추적 코어는 디버그 무관 항상 필요해 메인 번들에 남는다. 디버그 lazy 번들
|
||||
* (DevToolsPanel 등)은 이 shim 으로 메인 번들의 단일 인스턴스를 빌려 쓴다(싱글톤 동일성).
|
||||
* 값은 window.G7Core.__runtime, 타입은 원본에서 재export.
|
||||
*/
|
||||
|
||||
const runtime = (typeof window !== 'undefined'
|
||||
? (window as any).G7Core?.__runtime
|
||||
: undefined) as { G7DevToolsCore?: unknown } | undefined;
|
||||
|
||||
if (!runtime?.G7DevToolsCore) {
|
||||
throw new Error(
|
||||
'[devtools] window.G7Core.__runtime.G7DevToolsCore 이 없습니다. ' +
|
||||
'코어 번들(template-engine.min.js)이 devtools 번들보다 먼저 로드되어야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
export const G7DevToolsCore = runtime.G7DevToolsCore as typeof import('../G7DevToolsCore').G7DevToolsCore;
|
||||
export default G7DevToolsCore;
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* layout-editor-entry.ts — 레이아웃 편집기 lazy 번들 진입점
|
||||
*
|
||||
* @since engine-v1.51.0
|
||||
*
|
||||
* 이 파일은 별도 번들(`layout-editor.min.js`)의 엔트리다. 메인 코어 번들
|
||||
* (`template-engine.min.js`)에서 편집기 코드를 제거해 초기 접속 payload 를 줄이고,
|
||||
* `/admin/layout-editor/*` 진입 시에만 이 번들을 런타임 `<script>` 주입으로 로드한다.
|
||||
*
|
||||
* 역할:
|
||||
* 1. `LayoutEditorChrome` **컴포넌트**를 `window.G7Core.__LayoutEditorChrome` 로 노출.
|
||||
* - mount 함수가 아닌 컴포넌트 자체를 노출한다. provider wrapping(Translation/Transition/
|
||||
* Responsive/Slot)은 메인 번들 `template-engine.ts` `renderTemplate()` 이 그대로 유지하고,
|
||||
* 편집기 번들은 `ReactDOM.createRoot` 를 절대 호출하지 않는다(LayoutEditorChrome 규약).
|
||||
* 2. `__onChromeReady` 콜백 호출 — 메인 번들의 `loadLayoutEditorBundle()` 이 주입 전에
|
||||
* 등록해 둔 resolve. `onload` 이벤트를 fallback 경로로 병행한다.
|
||||
*
|
||||
* `LayoutEditorChrome` 를 import 하는 것만으로 편집기 트리 전체가 이 번들에 포함되고,
|
||||
* 모듈 로드 side-effect(`registerCoreWidgets`/`registerCoreEditors`/`exposeLayoutEditorGlobals`
|
||||
* — 메인 번들 stub 큐 flush)가 자동 실행된다.
|
||||
*
|
||||
* 코어 런타임(DynamicRenderer/엔진/컨텍스트)은 `vite.config.editor.js` 의 alias 로
|
||||
* `window.G7Core.__runtime` 에서 빌려 쓴다(0바이트 중복, 싱글톤/컨텍스트 동일성 보장).
|
||||
* React/ReactDOM/jsx-runtime 은 external 로 `window.React` 등을 참조한다.
|
||||
*/
|
||||
|
||||
import { LayoutEditorChrome } from './template-engine/layout-editor/LayoutEditorChrome';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const G7Core = (window as any).G7Core || ((window as any).G7Core = {});
|
||||
|
||||
// 편집기 셸 컴포넌트 노출 (메인 번들이 provider 트리 안에서 렌더)
|
||||
G7Core.__LayoutEditorChrome = LayoutEditorChrome;
|
||||
|
||||
// 준비 완료 통지 — loadLayoutEditorBundle() 이 주입 전에 등록한 resolve 호출
|
||||
if (typeof G7Core.__onChromeReady === 'function') {
|
||||
try {
|
||||
G7Core.__onChromeReady();
|
||||
} catch {
|
||||
/* 콜백 실패 격리 — onload fallback 이 별도로 resolve */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { LayoutEditorChrome };
|
||||
@@ -24,12 +24,15 @@ import { DataSourceManager } from './template-engine/DataSourceManager';
|
||||
import { ModalDataSourceWrapper } from './template-engine/ModalDataSourceWrapper';
|
||||
import { ParentContextProvider } from './template-engine/ParentContextProvider';
|
||||
import { TemplateNotFoundError } from './template-engine/TemplateEngineError';
|
||||
import { ErrorDisplay } from './template-engine/ErrorDisplay';
|
||||
import { TemplateApp, initTemplateApp } from './TemplateApp';
|
||||
import { createLogger } from './utils/Logger';
|
||||
import { webSocketManager } from './websocket/WebSocketManager';
|
||||
import { initializeG7CoreGlobals, initDevToolsAPI } from './template-engine/G7CoreGlobals';
|
||||
import { checkLayoutEditorMode } from './template-engine/layout-editor/hooks/useEditorMode';
|
||||
import { LayoutEditorChrome } from './template-engine/layout-editor/LayoutEditorChrome';
|
||||
// LayoutEditorChrome 은 정적 import 하지 않는다 — 편집기는 별도 lazy 번들
|
||||
// (layout-editor.min.js)로 분리되어 `/admin/layout-editor/*` 진입 시에만 로드된다.
|
||||
// @since engine-v1.51.0
|
||||
|
||||
const logger = createLogger('TemplateEngine');
|
||||
|
||||
@@ -368,6 +371,85 @@ async function initTemplateEngine(options: InitOptions): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집기 lazy 번들(layout-editor.min.js) 로드 후 LayoutEditorChrome 컴포넌트 반환.
|
||||
*
|
||||
* @since engine-v1.51.0
|
||||
*
|
||||
* 편집기는 메인 코어 번들에서 분리되어 `/admin/layout-editor/*` 진입 시에만 로드된다.
|
||||
* 이미 로드돼 있으면 즉시 반환(멱등), 아니면 `<script>` 를 1회 주입하고 로드 완료를 대기한다.
|
||||
* 동시 다중 호출은 in-flight promise 로 병합해 중복 주입을 막는다.
|
||||
*
|
||||
* @returns LayoutEditorChrome React 컴포넌트
|
||||
* @throws {Error} 스크립트 로드 실패 또는 컴포넌트 미노출 시
|
||||
*/
|
||||
let layoutEditorLoadPromise: Promise<any> | null = null;
|
||||
|
||||
function loadLayoutEditorBundle(): Promise<any> {
|
||||
const g7 = (window as any).G7Core;
|
||||
|
||||
// 이미 로드됨 — 즉시 반환 (멱등)
|
||||
if (g7?.__LayoutEditorChrome) {
|
||||
return Promise.resolve(g7.__LayoutEditorChrome);
|
||||
}
|
||||
|
||||
// 진행 중인 로드가 있으면 재사용 (중복 주입 방지)
|
||||
if (layoutEditorLoadPromise) {
|
||||
return layoutEditorLoadPromise;
|
||||
}
|
||||
|
||||
layoutEditorLoadPromise = new Promise((resolve, reject) => {
|
||||
// 번들 URL — blade 가 주입한 버전 포함 URL 우선, 폴백은 표준 경로
|
||||
const src =
|
||||
(window as any).G7Config?.coreEditorAsset || '/build/core/layout-editor.min.js';
|
||||
const scriptId = 'g7-layout-editor-bundle';
|
||||
|
||||
// 이미 주입된 <script> 재사용
|
||||
const existing = document.getElementById(scriptId) as HTMLScriptElement | null;
|
||||
|
||||
const onReady = () => {
|
||||
const chrome = (window as any).G7Core?.__LayoutEditorChrome;
|
||||
if (chrome) {
|
||||
resolve(chrome);
|
||||
} else {
|
||||
reject(new Error('편집기 번들 로드됨 — 그러나 __LayoutEditorChrome 미노출'));
|
||||
}
|
||||
};
|
||||
|
||||
// 편집기 엔트리가 컴포넌트 노출 직후 호출할 준비 완료 콜백 (onload fallback 병행)
|
||||
(window as any).G7Core = (window as any).G7Core || {};
|
||||
(window as any).G7Core.__onChromeReady = onReady;
|
||||
|
||||
if (existing) {
|
||||
// 주입돼 있으나 아직 실행 전 — load 이벤트 대기
|
||||
existing.addEventListener('load', onReady, { once: true });
|
||||
existing.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error(`편집기 번들 로드 실패: ${src}`)),
|
||||
{ once: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = scriptId;
|
||||
script.src = src;
|
||||
script.async = false; // 로드 순서 보존 (메인 번들 이후)
|
||||
script.addEventListener('load', onReady, { once: true });
|
||||
script.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
layoutEditorLoadPromise = null; // 재시도 가능하도록 초기화
|
||||
reject(new Error(`편집기 번들 로드 실패: ${src}`));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return layoutEditorLoadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 렌더링
|
||||
*
|
||||
@@ -429,6 +511,28 @@ async function renderTemplate(options: RenderOptions): Promise<void> {
|
||||
const editorMode = checkLayoutEditorMode(window.location.pathname);
|
||||
if (editorMode) {
|
||||
logger.log('레이아웃 편집기 모드 진입', editorMode);
|
||||
|
||||
// 편집기 lazy 번들 로드 (layout-editor.min.js) — 진입 시에만 로드
|
||||
let LayoutEditorChrome: any;
|
||||
try {
|
||||
LayoutEditorChrome = await loadLayoutEditorBundle();
|
||||
} catch (loadError) {
|
||||
logger.error('레이아웃 편집기 번들 로드 실패', loadError);
|
||||
// 컨테이너에 직접 에러 화면 렌더 (CSS 의존성 없는 인라인 스타일)
|
||||
ErrorDisplay.render(options.containerId, {
|
||||
title: '레이아웃 편집기 로드 실패',
|
||||
message:
|
||||
loadError instanceof Error
|
||||
? loadError.message
|
||||
: '편집기 번들을 불러오지 못했습니다.',
|
||||
icon: 'fas fa-triangle-exclamation',
|
||||
showStack: false,
|
||||
showReloadButton: true,
|
||||
debug: debugMode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const chrome = React.createElement(LayoutEditorChrome, {
|
||||
templateIdentifier: editorMode.templateIdentifier,
|
||||
initialLocale: state.locale,
|
||||
@@ -923,6 +1027,8 @@ export {
|
||||
getActionDispatcher,
|
||||
LayoutLoader,
|
||||
DataSourceManager,
|
||||
// @since engine-v1.51.0 편집기 lazy 번들 로더 (테스트/진단용 노출)
|
||||
loadLayoutEditorBundle,
|
||||
};
|
||||
|
||||
export type {
|
||||
|
||||
@@ -5,6 +5,30 @@
|
||||
>
|
||||
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||
|
||||
## [engine-v1.52.0] - 2026-07-02
|
||||
|
||||
### Changed
|
||||
|
||||
#### DevTools 번들 분리 (디버그 전용 코드 초기 로드 제거)
|
||||
|
||||
- 개발자 진단도구(DevTools)의 디버그 전용 무거운 모듈(패널 UI/진단엔진/서버커넥터/스타일추적기)을 메인 코어 번들에서 분리해 별도 `devtools.min.js` 로 빌드한다. `initDevToolsAPI()` 가 디버그 모드(`isEnabled()`)일 때만 런타임 `<script>` 주입으로 로드한다. 디버그 꺼진 일반 사용자는 이 코드를 받지 않는다. 편집기 분리(engine-v1.51.0)와 합쳐 초기 접속 gzip payload 458KB → 221KB (약 52% 감소).
|
||||
- `G7CoreGlobals.ts` — `DiagnosticEngine`/`ServerConnector`/`StyleTracker`/`DevToolsPanel` 정적 import 제거. `initDevToolsAPI()` 는 디버그 OFF 시 최소 API 만 노출(번들 미로드), ON 시 `loadDevToolsBundle()`(신규)로 `window.G7Core.__devtools` 를 지연 로드한 뒤 진단/서버덤프/패널을 활성화. `G7DevToolsCore`(추적 코어, 디버그 무관 항상 필요)는 메인 번들에 상주하며 `G7Core.__runtime` 으로 공유.
|
||||
- `devtools-entry.ts` + `vite.config.devtools.js`(신규) — DevTools 번들 엔트리/빌드. React external → `window.React` 단일 인스턴스, `G7DevToolsCore` 는 shim(`devtools/__runtime-shims/`)으로 메인 번들과 공유(재번들 0바이트, 싱글톤 동일성).
|
||||
- `core:build` — 엔진 + 편집기 + DevTools 3개 번들 순차 빌드. `--watch` 는 3개 `vite build --watch` 병렬.
|
||||
|
||||
## [engine-v1.51.0] - 2026-07-02
|
||||
|
||||
### Changed
|
||||
|
||||
#### 레이아웃 편집기 번들 분리 (초기 접속 payload 축소)
|
||||
|
||||
- 코어 번들(`template-engine.min.js`)에서 레이아웃 편집기 코드를 분리해 별도 `layout-editor.min.js` 로 빌드한다. 편집기는 `/admin/layout-editor/*` 진입 시에만 런타임 `<script>` 주입으로 지연 로드된다. 일반 페이지 초기 접속 gzip payload 458KB → 261KB (약 43% 감소).
|
||||
- `template-engine.ts` — `LayoutEditorChrome` 정적 import 제거. `renderTemplate()` 편집기 분기가 `loadLayoutEditorBundle()`(신규)로 번들을 지연 주입한 뒤 `window.G7Core.__LayoutEditorChrome` 를 기존과 동일한 provider 트리(Translation/Transition/Responsive/Slot) 안에서 렌더한다. 로드 실패 시 인라인 에러 화면 폴백. `checkLayoutEditorMode`(dep-free URL 파서)는 코어에 유지.
|
||||
- `G7CoreGlobals.ts` — `initCoreRuntimeExports()`(신규)가 코어 런타임 표면(DynamicRenderer/엔진 싱글톤/컨텍스트/Logger/AuthManager)을 `window.G7Core.__runtime` 으로 노출. 편집기 번들은 빌드 시 alias-shim(`layout-editor/__runtime-shims/`)으로 이 전역을 빌려 써 코어 런타임을 0바이트 중복으로 재사용하고 싱글톤/React Context 동일성을 보장한다.
|
||||
- `layout-editor-entry.ts` + `vite.config.editor.js`(신규) — 편집기 번들 엔트리/빌드 설정. React/ReactDOM/`react/jsx-runtime` 은 external → `window.React` 등 단일 인스턴스 재사용(자체 React 사본 미포함). 코어 런타임은 커스텀 `resolveId` 플러그인으로 shim 치환.
|
||||
- `core:build`(`BuildCoreCommand.php`) — 엔진 번들 + 편집기 번들을 순차 빌드. `--watch` 는 양 번들을 `vite build --watch` 로 병렬 감시.
|
||||
- 기존 편집기 확장점 stub/큐 핸드셰이크(`initLayoutEditorStub` → `exposeLayoutEditorGlobals`, engine-v1.50.0)가 번들 경계를 넘어 그대로 동작(편집기 로드 시 큐 flush).
|
||||
|
||||
## [engine-v1.50.0] - 2026-06-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -18,19 +18,21 @@ import * as ReactJSXRuntime from 'react/jsx-runtime';
|
||||
import { ComponentRegistry } from './ComponentRegistry';
|
||||
import { TranslationEngine, TranslationContext } from './TranslationEngine';
|
||||
import { ActionDispatcher } from './ActionDispatcher';
|
||||
import { DataBindingEngine } from './DataBindingEngine';
|
||||
import { DataBindingEngine, dataBindingEngine } from './DataBindingEngine';
|
||||
import { DataSourceManager, dataSourceManager } from './DataSourceManager';
|
||||
import DynamicRenderer from './DynamicRenderer';
|
||||
import { useTransitionState } from './TransitionContext';
|
||||
import { useTranslation } from './TranslationContext';
|
||||
import { useResponsive } from './ResponsiveContext';
|
||||
import { useTranslation, TranslationProvider, TranslationReactContext } from './TranslationContext';
|
||||
import { useResponsive, ResponsiveProvider, ResponsiveContext } from './ResponsiveContext';
|
||||
import { responsiveManager, BREAKPOINT_PRESETS } from './ResponsiveManager';
|
||||
import { AuthManager } from '../auth/AuthManager';
|
||||
import { getApiClient } from '../api/ApiClient';
|
||||
import { createLogger, flushEarlyLogs } from '../utils/Logger';
|
||||
import { WebSocketManager } from '../websocket/WebSocketManager';
|
||||
import { G7DevToolsCore } from '../devtools/G7DevToolsCore';
|
||||
import { DiagnosticEngine } from '../devtools/DiagnosticEngine';
|
||||
import { getServerConnector } from '../devtools/ServerConnector';
|
||||
import { DevToolsPanel } from '../devtools/ui/DevToolsPanel';
|
||||
import { getStyleTracker } from '../devtools/StyleTracker';
|
||||
// DiagnosticEngine/ServerConnector/StyleTracker/DevToolsPanel 은 정적 import 하지 않는다 —
|
||||
// 디버그 전용 무거운 모듈은 별도 lazy 번들(devtools.min.js)로 분리되어 initDevToolsAPI() 가
|
||||
// isEnabled() 참일 때만 런타임 <script> 주입으로 로드한다. @since engine-v1.52.0
|
||||
import type { DiagnosticCategory } from '../devtools/types';
|
||||
import {
|
||||
renderItemChildren,
|
||||
@@ -745,6 +747,70 @@ function initComponentEventSystem(G7Core: any): void {
|
||||
logger.log('전역 객체 window.G7Core.componentEvent에 노출됨');
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 런타임 표면을 `window.G7Core.__runtime` 으로 노출 — 편집기 lazy 번들 공유용.
|
||||
*
|
||||
* @since engine-v1.51.0
|
||||
*
|
||||
* 레이아웃 편집기 셸은 별도 번들(`layout-editor.min.js`)로 지연 로드된다(메인 번들 비대화
|
||||
* 회피). 편집기는 코어 런타임의 렌더러/엔진/컨텍스트를 재사용해야 하는데, 이때 **동일 인스턴스
|
||||
* 동일성**이 강제된다:
|
||||
*
|
||||
* - `getInstance()` 싱글톤(TranslationEngine 등)을 편집기 번들이 재번들하면 편집기 사본의
|
||||
* 싱글톤이 메인 번들과 fork → 번역/데이터소스 상태가 어긋난다.
|
||||
* - React Context(TranslationReactContext/ResponsiveContext 등)를 재번들하면 `createContext()`
|
||||
* 가 두 번 실행돼 Context 객체 참조가 달라짐 → 편집기의 `useContext` 가 빈 값을 읽는다.
|
||||
* - `DynamicRenderer` 는 사실상 코어 런타임 전체(ActionDispatcher/DataBindingEngine/모든
|
||||
* Context/sortable)를 끌어오므로, 재번들 시 코어 대부분이 편집기 번들에 중복된다.
|
||||
*
|
||||
* 이를 막기 위해 메인 번들이 이 런타임 표면을 `G7Core.__runtime` 에 노출하고, 편집기 빌드는
|
||||
* `resolve.alias` 로 `../DynamicRenderer` 등 상대 경로를 `G7Core.__runtime` 재export shim 으로
|
||||
* 치환한다(편집기 소스는 무수정). 결과적으로 편집기 번들은 코어 런타임을 0바이트 중복으로
|
||||
* **빌려 쓰고**, 싱글톤/컨텍스트 동일성이 자동 보장된다.
|
||||
*
|
||||
* 메인 `<script>` 는 동기 선행 실행되므로, 편집기 IIFE 가 실행될 때 `__runtime` 은 항상 존재한다.
|
||||
*
|
||||
* @param G7Core - window.G7Core 전역 객체
|
||||
*/
|
||||
function initCoreRuntimeExports(G7Core: any): void {
|
||||
// 이미 노출돼 있으면 멱등 (중복 init 방지)
|
||||
if (G7Core.__runtime) return;
|
||||
|
||||
// 노출 대상 = 편집기가 layout-editor/ 밖으로 직접 import 하는 11개 모듈의 런타임 값만.
|
||||
// (Slot/Transition Provider 는 편집기가 직접 import 하지 않고 메인 번들 renderTemplate 이
|
||||
// wrapping 하므로 __runtime 미포함. DynamicRenderer 내부의 Slot/Transition 의존은 메인 번들
|
||||
// DynamicRenderer 에 이미 번들돼 있어 별도 공유 불필요.)
|
||||
G7Core.__runtime = {
|
||||
// 렌더러 (default export) — 프리뷰 캔버스
|
||||
DynamicRenderer,
|
||||
// 엔진/레지스트리 클래스 (getInstance 싱글톤)
|
||||
ComponentRegistry,
|
||||
TranslationEngine,
|
||||
DataSourceManager,
|
||||
dataSourceManager,
|
||||
DataBindingEngine,
|
||||
dataBindingEngine,
|
||||
ActionDispatcher,
|
||||
// 번역 컨텍스트 (Context 객체 동일성 필수)
|
||||
TranslationReactContext,
|
||||
TranslationProvider,
|
||||
useTranslation,
|
||||
// 반응형 컨텍스트/매니저
|
||||
ResponsiveContext,
|
||||
ResponsiveProvider,
|
||||
useResponsive,
|
||||
responsiveManager,
|
||||
BREAKPOINT_PRESETS,
|
||||
// 인증/로거
|
||||
AuthManager,
|
||||
createLogger,
|
||||
// DevTools 추적 코어 (디버그 lazy 번들이 패널/진단엔진에서 공유) @since engine-v1.52.0
|
||||
G7DevToolsCore,
|
||||
};
|
||||
|
||||
logger.log('전역 객체 window.G7Core.__runtime(코어 런타임 표면)에 노출됨');
|
||||
}
|
||||
|
||||
/**
|
||||
* `G7Core.layoutEditor` 예약 접수함(ready 큐) 초기화 —.
|
||||
*
|
||||
@@ -3032,6 +3098,9 @@ export function initializeG7CoreGlobals(deps: G7CoreDependencies): void {
|
||||
|
||||
const G7Core = (window as any).G7Core;
|
||||
|
||||
// 코어 런타임 표면 노출 (편집기 lazy 번들이 __runtime 재export shim 으로 공유)
|
||||
initCoreRuntimeExports(G7Core);
|
||||
|
||||
// 각 API 그룹 초기화
|
||||
initComponentEventSystem(G7Core);
|
||||
// 레이아웃 편집기 확장점 예약 접수함(편집기 lazy 로드 전 템플릿 등록을 큐에 보존)
|
||||
@@ -3679,6 +3748,86 @@ export function initDevToolsInterface(): void {
|
||||
* G7DevTools.server.dumpState()
|
||||
* ```
|
||||
*/
|
||||
/**
|
||||
* DevTools 디버그 전용 모듈(패널 UI/진단엔진/서버커넥터/스타일추적기)의 lazy 번들 타입.
|
||||
* @since engine-v1.52.0
|
||||
*/
|
||||
interface DevToolsBundle {
|
||||
DiagnosticEngine: any;
|
||||
getServerConnector: () => any;
|
||||
getStyleTracker: () => any;
|
||||
DevToolsPanel: any;
|
||||
}
|
||||
|
||||
let devToolsBundleLoadPromise: Promise<DevToolsBundle> | null = null;
|
||||
|
||||
/**
|
||||
* DevTools lazy 번들(devtools.min.js)을 로드하고 디버그 전용 모듈 4종을 반환한다.
|
||||
*
|
||||
* @since engine-v1.52.0
|
||||
*
|
||||
* 디버그 모드에서만 호출된다. 이미 로드됨(멱등)/진행 중(in-flight 병합)/미로드(1회 주입)
|
||||
* 를 구분한다.
|
||||
*
|
||||
* @returns DevTools 디버그 전용 모듈 묶음
|
||||
* @throws {Error} 스크립트 로드 실패 또는 모듈 미노출 시
|
||||
*/
|
||||
export function loadDevToolsBundle(): Promise<DevToolsBundle> {
|
||||
const G7Core = (window as any).G7Core;
|
||||
|
||||
if (G7Core?.__devtools) {
|
||||
return Promise.resolve(G7Core.__devtools as DevToolsBundle);
|
||||
}
|
||||
if (devToolsBundleLoadPromise) {
|
||||
return devToolsBundleLoadPromise;
|
||||
}
|
||||
|
||||
devToolsBundleLoadPromise = new Promise<DevToolsBundle>((resolve, reject) => {
|
||||
const src = (window as any).G7Config?.coreDevToolsAsset || '/build/core/devtools.min.js';
|
||||
const scriptId = 'g7-devtools-bundle';
|
||||
|
||||
const onReady = () => {
|
||||
const bundle = (window as any).G7Core?.__devtools;
|
||||
if (bundle) {
|
||||
resolve(bundle as DevToolsBundle);
|
||||
} else {
|
||||
reject(new Error('DevTools 번들 로드됨 — 그러나 __devtools 미노출'));
|
||||
}
|
||||
};
|
||||
|
||||
(window as any).G7Core = (window as any).G7Core || {};
|
||||
(window as any).G7Core.__onDevToolsReady = onReady;
|
||||
|
||||
const existing = document.getElementById(scriptId) as HTMLScriptElement | null;
|
||||
if (existing) {
|
||||
existing.addEventListener('load', onReady, { once: true });
|
||||
existing.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error(`DevTools 번들 로드 실패: ${src}`)),
|
||||
{ once: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = scriptId;
|
||||
script.src = src;
|
||||
script.async = false;
|
||||
script.addEventListener('load', onReady, { once: true });
|
||||
script.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
devToolsBundleLoadPromise = null;
|
||||
reject(new Error(`DevTools 번들 로드 실패: ${src}`));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return devToolsBundleLoadPromise;
|
||||
}
|
||||
|
||||
export function initDevToolsAPI(): void {
|
||||
const G7Core = (window as any).G7Core;
|
||||
if (!G7Core) {
|
||||
@@ -3688,7 +3837,7 @@ export function initDevToolsAPI(): void {
|
||||
const devToolsCore = G7DevToolsCore.getInstance();
|
||||
devToolsCore.initialize();
|
||||
|
||||
// DevTools가 비활성화된 경우 최소 API만 노출
|
||||
// DevTools가 비활성화된 경우 최소 API만 노출 (devtools.min.js 미로드 — 초기 payload 절감)
|
||||
if (!devToolsCore.isEnabled()) {
|
||||
(window as any).G7DevTools = {
|
||||
isEnabled: () => false,
|
||||
@@ -3702,6 +3851,31 @@ export function initDevToolsAPI(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// 디버그 모드 — DevTools lazy 번들 로드 후 전체 API 활성화 (fire-and-forget)
|
||||
void setupDevToolsWhenEnabled(G7Core, devToolsCore);
|
||||
}
|
||||
|
||||
/**
|
||||
* 디버그 모드에서 DevTools lazy 번들을 로드하고 전체 API 를 활성화한다.
|
||||
*
|
||||
* @since engine-v1.52.0
|
||||
*
|
||||
* @param G7Core - window.G7Core 전역 객체
|
||||
* @param devToolsCore - G7DevToolsCore 싱글톤 인스턴스
|
||||
*/
|
||||
async function setupDevToolsWhenEnabled(G7Core: any, devToolsCore: any): Promise<void> {
|
||||
let bundle: DevToolsBundle;
|
||||
try {
|
||||
bundle = await loadDevToolsBundle();
|
||||
} catch (error) {
|
||||
logger.warn('DevTools 번들 로드 실패 — 최소 API 로 폴백:', error);
|
||||
(window as any).G7DevTools = { isEnabled: () => false, enable: () => {} };
|
||||
flushEarlyLogs();
|
||||
return;
|
||||
}
|
||||
|
||||
const { DiagnosticEngine, getServerConnector, getStyleTracker } = bundle;
|
||||
|
||||
// G7Core.devTools 인터페이스 초기화 (renderItemChildren 등에서 사용)
|
||||
initDevToolsInterface();
|
||||
|
||||
@@ -3976,8 +4150,8 @@ export function initDevToolsAPI(): void {
|
||||
|
||||
logger.log('G7DevTools 전역 객체 초기화 완료 (window.G7DevTools)');
|
||||
|
||||
// DevToolsPanel UI 렌더링
|
||||
renderDevToolsPanel();
|
||||
// DevToolsPanel UI 렌더링 (lazy 번들에서 로드한 컴포넌트 전달)
|
||||
renderDevToolsPanel(bundle.DevToolsPanel);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3985,8 +4159,10 @@ export function initDevToolsAPI(): void {
|
||||
*
|
||||
* 별도의 DOM 컨테이너를 생성하여 메인 앱과 독립적으로 렌더링합니다.
|
||||
* 디버그 모드가 활성화된 경우에만 렌더링됩니다.
|
||||
*
|
||||
* @param DevToolsPanel - lazy 번들(devtools.min.js)에서 로드한 패널 컴포넌트
|
||||
*/
|
||||
function renderDevToolsPanel(): void {
|
||||
function renderDevToolsPanel(DevToolsPanel: any): void {
|
||||
// 이미 렌더링되어 있으면 스킵
|
||||
if (document.getElementById('g7-devtools-root')) {
|
||||
logger.log('DevToolsPanel 이미 렌더링됨');
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* ActionDispatcher 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* 값은 window.G7Core.__runtime(싱글톤 동일성 필수), 타입은 원본에서 재export.
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
export const ActionDispatcher = getCoreRuntime().ActionDispatcher;
|
||||
|
||||
export type {
|
||||
ActionType,
|
||||
EventType,
|
||||
ActionDefinition,
|
||||
NavigateOptions,
|
||||
IsolatedContextValue,
|
||||
ActionContext,
|
||||
LoadingActionsMap,
|
||||
ActionHandler,
|
||||
ActionResult,
|
||||
} from '../../ActionDispatcher';
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* AuthManager 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* 값은 window.G7Core.__runtime(인증 싱글톤 동일성 필수).
|
||||
* 원본 경로: resources/js/core/auth/AuthManager (본 shim 기준 ../../../auth/AuthManager)
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
export const AuthManager = getCoreRuntime().AuthManager;
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* ComponentRegistry 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* 값은 window.G7Core.__runtime(싱글톤 동일성 필수), 타입은 원본에서 재export.
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
export const ComponentRegistry = getCoreRuntime().ComponentRegistry;
|
||||
|
||||
export type {
|
||||
ComponentTypeEnum,
|
||||
ComponentMetadata,
|
||||
ComponentManifest,
|
||||
RegistryMap,
|
||||
LoadingState,
|
||||
} from '../../ComponentRegistry';
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* DataBindingEngine 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* 값은 window.G7Core.__runtime(싱글톤 동일성 필수), 타입은 원본에서 재export.
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
const rt = getCoreRuntime();
|
||||
|
||||
export const DataBindingEngine = rt.DataBindingEngine;
|
||||
export const dataBindingEngine = rt.dataBindingEngine;
|
||||
|
||||
export type { BindingContext, ResolvedValue, BindingOptions } from '../../DataBindingEngine';
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* DataSourceManager 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* 값은 window.G7Core.__runtime(싱글톤 동일성 필수), 타입은 원본에서 재export.
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
const rt = getCoreRuntime();
|
||||
|
||||
export const DataSourceManager = rt.DataSourceManager;
|
||||
export const dataSourceManager = rt.dataSourceManager;
|
||||
|
||||
export type {
|
||||
DataSourceType,
|
||||
WebSocketChannelType,
|
||||
HttpMethod,
|
||||
LoadingStrategy,
|
||||
DataSourceLoadingState,
|
||||
DataSource,
|
||||
DataSourceResult,
|
||||
DataSourceManagerOptions,
|
||||
SampleDataProvider,
|
||||
ConditionContext,
|
||||
} from '../../DataSourceManager';
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* DynamicRenderer 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
*
|
||||
* @since engine-v1.51.0
|
||||
*
|
||||
* 편집기 번들은 `../../DynamicRenderer` import 를 이 shim 으로 치환해, 코어 런타임의
|
||||
* DynamicRenderer 를 `window.G7Core.__runtime` 에서 빌려 쓴다(재번들 0바이트, 싱글톤 동일성).
|
||||
* 값은 런타임 전역에서, 타입은 원본 모듈에서 가져온다(타입은 컴파일 시 소거 → 런타임 코드 미포함).
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
// 값: 코어 런타임 전역에서 획득 (default export 재현)
|
||||
const DynamicRenderer = getCoreRuntime().DynamicRenderer;
|
||||
|
||||
export default DynamicRenderer;
|
||||
|
||||
// 타입: 원본에서 재export (런타임 코드 없음)
|
||||
export type {
|
||||
ComponentDefinition,
|
||||
DynamicRendererProps,
|
||||
} from '../../DynamicRenderer';
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Logger 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* 값은 window.G7Core.__runtime(로거 팩토리 동일성), 타입은 원본에서 재export.
|
||||
* 원본 경로: resources/js/core/utils/Logger (본 shim 기준 ../../../../utils/Logger)
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
export const createLogger = getCoreRuntime().createLogger;
|
||||
|
||||
export type { Logger } from '../../../utils/Logger';
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* ResponsiveContext 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* React Context 객체 동일성 필수 → window.G7Core.__runtime 에서 값 획득.
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
const rt = getCoreRuntime();
|
||||
|
||||
export const ResponsiveProvider = rt.ResponsiveProvider;
|
||||
export const useResponsive = rt.useResponsive;
|
||||
export const ResponsiveContext = rt.ResponsiveContext;
|
||||
|
||||
export type { ResponsiveContextValue } from '../../ResponsiveContext';
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* ResponsiveManager 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* 값은 window.G7Core.__runtime(싱글톤 동일성 필수).
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
const rt = getCoreRuntime();
|
||||
|
||||
export const responsiveManager = rt.responsiveManager;
|
||||
export const BREAKPOINT_PRESETS = rt.BREAKPOINT_PRESETS;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* TranslationContext 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* React Context 객체 동일성 필수 → window.G7Core.__runtime 에서 값 획득.
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
const rt = getCoreRuntime();
|
||||
|
||||
export const TranslationProvider = rt.TranslationProvider;
|
||||
export const useTranslation = rt.useTranslation;
|
||||
export const TranslationReactContext = rt.TranslationReactContext;
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* TranslationEngine 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* 값은 window.G7Core.__runtime(싱글톤 동일성 필수), 타입은 원본에서 재export.
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
export const TranslationEngine = getCoreRuntime().TranslationEngine;
|
||||
|
||||
export type {
|
||||
TranslationDictionary,
|
||||
TranslationParams,
|
||||
TranslationOptions,
|
||||
TranslationContext,
|
||||
} from '../../TranslationEngine';
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 코어 런타임 접근 헬퍼 — 편집기 번들 전용 shim 공용 유틸
|
||||
*
|
||||
* @since engine-v1.51.0
|
||||
*
|
||||
* 편집기 lazy 번들의 런타임 shim 들이 `window.G7Core.__runtime`(메인 번들이 노출)에서
|
||||
* 코어 런타임 값을 안전하게 획득한다. 메인 `<script>` 가 동기 선행 실행되므로 편집기 IIFE
|
||||
* 실행 시점에 `__runtime` 은 항상 존재하지만, 부재 시 명확한 에러로 조기 실패시킨다.
|
||||
*/
|
||||
|
||||
interface CoreRuntime {
|
||||
DynamicRenderer: any;
|
||||
ComponentRegistry: any;
|
||||
TranslationEngine: any;
|
||||
DataSourceManager: any;
|
||||
dataSourceManager: any;
|
||||
DataBindingEngine: any;
|
||||
dataBindingEngine: any;
|
||||
ActionDispatcher: any;
|
||||
TranslationReactContext: any;
|
||||
TranslationProvider: any;
|
||||
useTranslation: any;
|
||||
TransitionProvider: any;
|
||||
useTransitionState: any;
|
||||
ResponsiveContext: any;
|
||||
ResponsiveProvider: any;
|
||||
useResponsive: any;
|
||||
responsiveManager: any;
|
||||
BREAKPOINT_PRESETS: any;
|
||||
SlotProvider: any;
|
||||
useSlot: any;
|
||||
AuthManager: any;
|
||||
createLogger: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* `window.G7Core.__runtime` 반환 — 부재 시 즉시 throw.
|
||||
*
|
||||
* @returns 코어 런타임 표면 객체
|
||||
* @throws {Error} 메인 번들 미로드로 __runtime 부재 시
|
||||
*/
|
||||
export function getCoreRuntime(): CoreRuntime {
|
||||
const runtime = (typeof window !== 'undefined'
|
||||
? (window as any).G7Core?.__runtime
|
||||
: undefined) as CoreRuntime | undefined;
|
||||
|
||||
if (!runtime) {
|
||||
throw new Error(
|
||||
'[layout-editor] window.G7Core.__runtime 이 없습니다. ' +
|
||||
'코어 번들(template-engine.min.js)이 편집기 번들보다 먼저 로드되어야 합니다.'
|
||||
);
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
+4
-4
@@ -273,7 +273,7 @@ describe('requestPgPayment — placeholder 핸들러 build/match 라운드트립
|
||||
|
||||
// 회귀 — "+동작 추가" 로 결제 진입을 고르면 ActionAddPicker 가 buildAction(recipe, {}) 로 빈 값
|
||||
// 빌드한다. placeholder 핸들러({{paymentHandler}})가 빈 값에서 undefined 로 떨어져 사라지면
|
||||
// handler 없는 액션이 되어 "알 수 없는 동작"(advanced)으로 강등된다(PO 제보). 빈 빌드에서도
|
||||
// handler 없는 액션이 되어 "알 수 없는 동작"(advanced)으로 강등된다(제보 회귀). 빈 빌드에서도
|
||||
// handler placeholder 는 보존돼야 추가 직후 친화 카드로 매칭되고 사용자가 칩으로 채울 수 있다.
|
||||
it('빈 값 빌드(+동작 추가) — placeholder 핸들러가 보존되어 handler 가 사라지지 않는다', () => {
|
||||
const recipe = recipes.find((r) => r.id === 'requestPgPayment')!;
|
||||
@@ -290,14 +290,14 @@ describe('requestPgPayment — placeholder 핸들러 build/match 라운드트립
|
||||
expect(m?.recipeId).toBe('requestPgPayment');
|
||||
});
|
||||
|
||||
// 회귀(PO 제보) — 핸들러 필드를 데이터 칩으로 바꾸고 결제 데이터는 비워두면, buildAction 이
|
||||
// 회귀(제보) — 핸들러 필드를 데이터 칩으로 바꾸고 결제 데이터는 비워두면, buildAction 이
|
||||
// 미입력 required pgPaymentData 를 떨궈 params 가 사라진다. 그러면 fingerprint(pgPaymentData)
|
||||
// 가 깨져 친화 카드가 [고급]으로 강등됐다(PO: "변경하니 고급으로 넘어간다"). required placeholder
|
||||
// 가 깨져 친화 카드가 [고급]으로 강등됐다("변경하니 고급으로 넘어간다"). required placeholder
|
||||
// param 은 빈 값이어도 토큰을 보존해 fingerprint 를 유지하고, 핸들러를 어떤 칩으로 바꿔도 친화
|
||||
// 카드가 유지돼야 한다(핸들러 필드 바인딩 가능성 ↔ greedy 가드 공존).
|
||||
it('핸들러를 임의 칩으로 바꾸고 데이터는 비워도 requestPgPayment 친화 카드 유지 (고급 강등 금지)', () => {
|
||||
const recipe = recipes.find((r) => r.id === 'requestPgPayment')!;
|
||||
// 핸들러만 데이터 칩으로 채우고 pgPaymentData 는 미입력(빈 값) — PO 시나리오.
|
||||
// 핸들러만 데이터 칩으로 채우고 pgPaymentData 는 미입력(빈 값) — 제보 시나리오.
|
||||
const action = buildAction(recipe, { paymentHandler: '{{checkoutData?.data?.temp_order_id}}' });
|
||||
// required pgPaymentData 가 토큰으로 보존돼 params 가 살아있어야 한다.
|
||||
expect(action.handler).toBe('{{checkoutData?.data?.temp_order_id}}');
|
||||
|
||||
@@ -221,7 +221,7 @@ export function buildAction(
|
||||
// required param 의 sole-binding 토큰이 미입력으로 떨어지면 build 틀의 토큰을 복원한다. 이런 recipe
|
||||
// (결제 진입)는 matchAction 의 구조 fingerprint(placeholderRecipeStructureMatches)가 params 의
|
||||
// 토큰 존재에 의존하므로, 핸들러 필드를 임의 데이터 칩으로 바꾸고 다른 required 값을 비워둬도 친화
|
||||
// 카드가 [고급]으로 강등되지 않게 토큰을 유지한다(PO 제보 회귀). 일반 recipe(리터럴 핸들러,
|
||||
// 카드가 [고급]으로 강등되지 않게 토큰을 유지한다(제보 회귀). 일반 recipe(리터럴 핸들러,
|
||||
// apiCall 등)는 미입력 required 를 그대로 떨궈 깔끔한 JSON 정책을 지킨다 — 그쪽은 핸들러 일치만으로
|
||||
// 매칭되므로 토큰 보존이 필요 없다.
|
||||
if (recipe.build.handler.includes('{{')) {
|
||||
|
||||
@@ -119,7 +119,7 @@ export interface EditorSpec {
|
||||
* 다크 모드 표현 선언 — (편집기 프리뷰 다크 격리).
|
||||
*
|
||||
* 템플릿이 어떤 방식으로 다크 모드를 표현하는지 선언한다(라이브러리 중립).
|
||||
* 편집기 프리뷰는 PO admin 환경의 `html.dark` 조상과 독립적으로 라이트/다크를
|
||||
* 편집기 프리뷰는 관리자 admin 환경의 `html.dark` 조상과 독립적으로 라이트/다크를
|
||||
* 보여줘야 하는데(조상 `.dark` 가 프리뷰 라이트 격리를 깸), 코어 CSS 서빙 API 가
|
||||
* 이 선언대로 편집기용 CSS 의 다크 셀렉터를 프리뷰 전용 마커로 치환해 서빙한다.
|
||||
* 일반 사용자 페이지 CSS 는 원본 그대로(무영향). 미선언/`none` 이면 다크 탭 비노출.
|
||||
|
||||
@@ -42,6 +42,12 @@
|
||||
activeModules: @json($activeModulesMeta ?? []),
|
||||
activePlugins: @json($activePluginsMeta ?? []),
|
||||
appConfig: @json($appConfig ?? []),
|
||||
// 레이아웃 편집기 lazy 번들 URL — `/admin/layout-editor/*` 진입 시에만 런타임
|
||||
// <script> 주입으로 로드된다(초기 접속 payload 에 미포함). filemtime 캐시버스팅,
|
||||
// 미빌드 상태 대비 file_exists 가드.
|
||||
coreEditorAsset: '{{ asset('build/core/layout-editor.min.js') }}?v={{ file_exists(public_path('build/core/layout-editor.min.js')) ? filemtime(public_path('build/core/layout-editor.min.js')) : 0 }}',
|
||||
// DevTools lazy 번들 URL — 디버그 모드에서만 런타임 <script> 주입으로 로드.
|
||||
coreDevToolsAsset: '{{ asset('build/core/devtools.min.js') }}?v={{ file_exists(public_path('build/core/devtools.min.js')) ? filemtime(public_path('build/core/devtools.min.js')) : 0 }}',
|
||||
// 확장(코어/모듈/플러그인) 캐시 버전 SSoT — install/activate/deactivate/update 시 bump.
|
||||
// 클라이언트 fetch (`?v=`) 가 이 값을 동반해야 백엔드 `template.routes.{id}.v{N}`
|
||||
// 키가 새 버전으로 전환되어 routes.json/lang 변경이 즉시 가시화된다.
|
||||
|
||||
@@ -42,6 +42,12 @@
|
||||
activeModules: @json($activeModulesMeta ?? []),
|
||||
activePlugins: @json($activePluginsMeta ?? []),
|
||||
appConfig: @json($appConfig ?? []),
|
||||
// 레이아웃 편집기 lazy 번들 URL — `/admin/layout-editor/*` 진입 시에만 런타임
|
||||
// <script> 주입으로 로드된다(초기 접속 payload 에 미포함). filemtime 캐시버스팅,
|
||||
// 미빌드 상태 대비 file_exists 가드.
|
||||
coreEditorAsset: '{{ asset('build/core/layout-editor.min.js') }}?v={{ file_exists(public_path('build/core/layout-editor.min.js')) ? filemtime(public_path('build/core/layout-editor.min.js')) : 0 }}',
|
||||
// DevTools lazy 번들 URL — 디버그 모드에서만 런타임 <script> 주입으로 로드.
|
||||
coreDevToolsAsset: '{{ asset('build/core/devtools.min.js') }}?v={{ file_exists(public_path('build/core/devtools.min.js')) ? filemtime(public_path('build/core/devtools.min.js')) : 0 }}',
|
||||
// 확장 캐시 버전 SSoT — 클라이언트 fetch (`?v=`) 동반 필수.
|
||||
// 자세한 설명은 admin.blade.php 참조.
|
||||
cache_version: {{ (int) ($extensionCacheVersion ?? 0) }}
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
namespace Tests\Feature\Api\Admin;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Repositories\AttachmentRepositoryInterface;
|
||||
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
@@ -732,6 +734,10 @@ class SettingsControllerTest extends TestCase
|
||||
|
||||
/**
|
||||
* 시스템 정보 응답 구조 검증
|
||||
*
|
||||
* @scenario probe_item=cpu_info,probe_outcome=success,auth_state=authenticated_with_permission
|
||||
*
|
||||
* @effects all_probes_success_returns_full_payload
|
||||
*/
|
||||
public function test_system_info_returns_correct_structure(): void
|
||||
{
|
||||
@@ -1365,4 +1371,210 @@ class SettingsControllerTest extends TestCase
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['success' => true]);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 시스템 정보 probe 실패 격리 테스트 — gnuboard/g7#59
|
||||
// (구조/캐시/정상값은 위 systemInfo 테스트가 이미 커버. 여기서는 호스팅
|
||||
// disable_functions/open_basedir 로 probe 가 실패해도 전체 200 을 유지하는지 검증)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 인증 없이 시스템 정보 조회 시 401 반환 (probe 실행 전 인증 가드 차단)
|
||||
*
|
||||
* @scenario probe_item=cpu_info,probe_outcome=success,auth_state=unauthenticated
|
||||
*
|
||||
* @effects unauthenticated_returns_401
|
||||
*/
|
||||
public function test_system_info_returns_401_without_authentication(): void
|
||||
{
|
||||
$response = $this->getJson('/api/admin/settings/system-info');
|
||||
|
||||
$response->assertStatus(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* read 권한 없이 시스템 정보 조회 시 403 반환 (probe 실행 전 권한 가드 차단)
|
||||
*
|
||||
* @scenario probe_item=cpu_info,probe_outcome=success,auth_state=authenticated_without_permission
|
||||
*
|
||||
* @effects without_permission_returns_403
|
||||
*/
|
||||
public function test_system_info_returns_403_without_permission(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$adminRole = Role::firstOrCreate(
|
||||
['identifier' => 'admin'],
|
||||
[
|
||||
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
|
||||
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Administrator']),
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$readPermission = Permission::where('identifier', 'core.settings.read')->first();
|
||||
if ($adminRole && $readPermission) {
|
||||
$adminRole->permissions()->detach($readPermission->id);
|
||||
}
|
||||
|
||||
$user->roles()->attach($adminRole->id, [
|
||||
'assigned_at' => now(),
|
||||
'assigned_by' => null,
|
||||
]);
|
||||
|
||||
$token = $user->createToken('test-token')->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson('/api/admin/settings/system-info');
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* probe 실패 격리(핵심 회귀 #59): 개별 항목 수집이 예외/Error 를 던져도
|
||||
* API 전체는 500 이 아니라 200 을 반환하고, 실패 항목만 'unknown' 폴백으로 채운다.
|
||||
*
|
||||
* disable_functions/open_basedir 호스팅에서 php_uname·shell_exec·disk_*_space 가
|
||||
* Error 를 던지는 상황을 getCpuInfo() 로 시뮬레이션한다. safeSystemProbe 가 이를
|
||||
* 격리하지 못하면 buildSystemInfo → getSystemInfo → 컨트롤러로 전파되어 500 이 난다.
|
||||
*
|
||||
* @scenario probe_item=cpu_info,probe_outcome=throws_error,auth_state=authenticated_with_permission
|
||||
*
|
||||
* @effects failing_probe_isolated_and_api_returns_200
|
||||
* @effects failed_item_filled_with_unknown_fallback
|
||||
*/
|
||||
public function test_system_info_isolates_failing_probe_and_returns_200(): void
|
||||
{
|
||||
// 캐시된 정상 페이로드가 있으면 buildSystemInfo 가 재실행되지 않으므로 먼저 비운다.
|
||||
$cache = $this->app->make(CacheInterface::class);
|
||||
$cache->forget('settings.system_info.'.app()->getLocale());
|
||||
|
||||
$service = new class($this->app) extends SettingsService
|
||||
{
|
||||
public function __construct($app)
|
||||
{
|
||||
parent::__construct(
|
||||
$app->make(ConfigRepositoryInterface::class),
|
||||
$app->make(AttachmentRepositoryInterface::class),
|
||||
$app->make(CacheInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
protected function getCpuInfo(): string
|
||||
{
|
||||
throw new \Error('Call to undefined function shell_exec() (disable_functions)');
|
||||
}
|
||||
};
|
||||
$this->app->instance(SettingsService::class, $service);
|
||||
|
||||
$response = $this->authRequest()->getJson('/api/admin/settings/system-info');
|
||||
|
||||
// 실패한 cpu_info 만 unknown 폴백, 나머지 항목은 정상 수집되어 전체 200 유지.
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'cpu_info' => __('common.unknown'),
|
||||
],
|
||||
])
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'memory_usage' => ['total', 'used', 'free', 'percentage'],
|
||||
'disk_usage' => ['total', 'used', 'free', 'percentage'],
|
||||
'php_extensions' => ['required', 'optional'],
|
||||
'server_time',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용량 조회 실패 시 폴백 배열 구조 검증: unknownUsage 는 total/used/free 를
|
||||
* 'unknown' 으로, percentage 를 0 으로 채워 memory_usage·disk_usage 형식을 유지한다.
|
||||
*
|
||||
* @scenario probe_item=memory_usage,probe_outcome=throws_error,auth_state=authenticated_with_permission
|
||||
*
|
||||
* @effects usage_array_fallback_keeps_total_used_free_unknown_and_percentage_zero
|
||||
*/
|
||||
public function test_unknown_usage_fallback_keeps_usage_array_shape(): void
|
||||
{
|
||||
$service = $this->app->make(SettingsService::class);
|
||||
|
||||
$ref = new \ReflectionMethod($service, 'unknownUsage');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$fallback = $ref->invoke($service);
|
||||
|
||||
$this->assertSame(
|
||||
['total', 'used', 'free', 'percentage'],
|
||||
array_keys($fallback)
|
||||
);
|
||||
$this->assertSame(__('common.unknown'), $fallback['total']);
|
||||
$this->assertSame(__('common.unknown'), $fallback['used']);
|
||||
$this->assertSame(__('common.unknown'), $fallback['free']);
|
||||
$this->assertSame(0, $fallback['percentage']);
|
||||
}
|
||||
|
||||
/**
|
||||
* safeSystemProbe 단위 검증: 콜백이 \Error 를 던지면 전파하지 않고 폴백을 반환한다.
|
||||
*/
|
||||
public function test_safe_system_probe_returns_fallback_on_error(): void
|
||||
{
|
||||
$service = $this->app->make(SettingsService::class);
|
||||
|
||||
$ref = new \ReflectionMethod($service, 'safeSystemProbe');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$result = $ref->invoke($service, 'cpu_info', function () {
|
||||
throw new \Error('disable_functions');
|
||||
}, 'FALLBACK');
|
||||
|
||||
$this->assertSame('FALLBACK', $result);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 설정 복원 (restore) 검증 — RestoreSettingsRequest 이전 회귀
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 인증 없이 설정 복원 요청 시 401 반환
|
||||
*/
|
||||
public function test_restore_returns_401_without_authentication(): void
|
||||
{
|
||||
$response = $this->postJson('/api/admin/settings/restore', ['backup_path' => 'foo']);
|
||||
|
||||
$response->assertStatus(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* update 권한 없이 설정 복원 요청 시 403 반환
|
||||
*/
|
||||
public function test_restore_returns_403_without_update_permission(): void
|
||||
{
|
||||
$user = $this->createAdminUser(['core.settings.read']);
|
||||
$token = $user->createToken('test-token')->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Accept' => 'application/json',
|
||||
])->postJson('/api/admin/settings/restore', ['backup_path' => 'foo']);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* backup_path 누락 시 FormRequest 검증으로 422 반환
|
||||
*
|
||||
* 회귀: 검증을 컨트롤러 내 empty() 체크에서 RestoreSettingsRequest 로 이전한 뒤에도
|
||||
* 백업 경로 미입력이 422 로 차단되는지 확인한다.
|
||||
*/
|
||||
public function test_restore_returns_422_when_backup_path_missing(): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson('/api/admin/settings/restore', []);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['backup_path']);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use Tests\TestCase;
|
||||
/**
|
||||
* `spawn_failure_mode` + `[STEPS_EXECUTED]` 신호 + stale 메모리 가드 회귀 테스트 (§2/§2.1/§6).
|
||||
*
|
||||
* 회귀 시나리오 (이슈 #28):
|
||||
* 회귀 시나리오 (gnuboard/g7#28):
|
||||
* - spawn 자식 실패 → in-process fallback 진입 → 부모 메모리 stale → upgrade step
|
||||
* 안에서 신규 메서드 호출 fatal (예: `Call to undefined method ensureWritableDirectories()`)
|
||||
*
|
||||
@@ -96,31 +96,107 @@ class CoreUpdateCommandSpawnFailureTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function spawn_자식_silent_skip_시_abort_모드는_throw_한다(): void
|
||||
public function spawn_자식_범위내_스텝_파일이_없어_0건이면_정상_통과한다(): void
|
||||
{
|
||||
if (! function_exists('proc_open')) {
|
||||
$this->markTestSkipped('proc_open 미지원 환경');
|
||||
}
|
||||
|
||||
// upgrade 파일 없이 호출 → 자식은 [STEPS_EXECUTED] count=0 발행 + exit=0
|
||||
// from < to 인 상태에서 0건 실행은 비정상 → mode=abort 시 throw
|
||||
// upgrade 파일 없이 호출 → 자식은 [STEPS_EXECUTED] executed=0, discovered=0 발행 + exit=0
|
||||
// "범위 내 스텝 파일 자체가 없음" 은 정상 상황 (예: 스텝 불필요 릴리즈).
|
||||
// discovered=0 이면 fail-fast 를 발동하지 않고 정상 통과해야 한다 (케이스 B).
|
||||
config(['app.update.spawn_failure_mode' => 'abort']);
|
||||
|
||||
$command = $this->makeCommandWithDummyIo();
|
||||
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'spawnUpgradeStepsProcess');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$logs = [];
|
||||
$log = function (string $msg) use (&$logs): void {
|
||||
$logs[] = $msg;
|
||||
};
|
||||
|
||||
// 실 스텝 파일이 존재하지 않는 버전 범위 (9.9.8 → 9.9.9)
|
||||
$result = $method->invoke($command, '9.9.8', '9.9.9', true, $log);
|
||||
|
||||
$this->assertTrue($result, '범위 내 스텝 파일이 없어 0건이면 정상 통과(true)여야 한다');
|
||||
$allLogs = implode("\n", $logs);
|
||||
$this->assertStringContainsString('실행할 스텝 없음', $allLogs, '스텝 부재 정상 통과 로그');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function spawn_자식_범위내_스텝이_있는데_0건_실행이면_abort_throw_한다(): void
|
||||
{
|
||||
// 범위 내에 스텝 파일이 실제로 존재(discovered>0)하는데 executed=0 인 경우 —
|
||||
// 이전 버전 자식이 신규 스텝을 놓치고 silent skip 한 gnuboard/g7#28 케이스 (케이스 A).
|
||||
// 이 경우에만 fail-fast 를 발동해야 한다.
|
||||
//
|
||||
// 재현: 스텝 파일은 존재하나 자식의 runUpgradeSteps 가 executed 를 0 으로 보고하도록,
|
||||
// discovered 는 세되 onStep 은 호출하지 않는 상황을 자식 프로세스 안에서 만들 수 없으므로,
|
||||
// 부모 가드 로직(handleSpawnExit)을 직접 호출해 discovered>0 && executed=0 분기를 검증한다.
|
||||
config(['app.update.spawn_failure_mode' => 'abort']);
|
||||
|
||||
$command = $this->makeCommandWithDummyIo();
|
||||
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'handleSpawnExit');
|
||||
$method->setAccessible(true);
|
||||
|
||||
try {
|
||||
$method->invoke($command, '9.9.8', '9.9.9', true, fn () => null);
|
||||
$this->fail('step 0건 실행 시 mode=abort 에서 throw 되어야 한다');
|
||||
// exitCode=0, handoffPayload=null, stepsExecuted=0, stepsDiscovered=5
|
||||
$method->invoke($command, 0, null, 0, 5, '9.9.8', '9.9.9', fn () => null);
|
||||
$this->fail('discovered>0 && executed=0 이면 mode=abort 에서 throw 되어야 한다');
|
||||
} catch (UpgradeHandoffException $e) {
|
||||
$this->assertStringContainsString('step 0건 실행', $e->reason);
|
||||
$this->assertStringContainsString('발견 5건', $e->reason);
|
||||
$this->assertSame('9.9.8', $e->afterVersion);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function spawn_자식_silent_skip_시_fallback_모드는_false_반환한다(): void
|
||||
public function handleSpawnExit_executed_0_discovered_0_이면_from_lt_to_여도_정상_통과한다(): void
|
||||
{
|
||||
// 케이스 B — 범위 내 스텝 파일 부재. from<to 여도 실패 아님 (스텝 불필요 릴리즈).
|
||||
// proc_open 불필요한 순수 판정 로직 단위 검증.
|
||||
config(['app.update.spawn_failure_mode' => 'abort']);
|
||||
|
||||
$command = $this->makeCommandWithDummyIo();
|
||||
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'handleSpawnExit');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$logs = [];
|
||||
$log = function (string $msg) use (&$logs): void {
|
||||
$logs[] = $msg;
|
||||
};
|
||||
|
||||
// exitCode=0, handoffPayload=null, stepsExecuted=0, stepsDiscovered=0
|
||||
$result = $method->invoke($command, 0, null, 0, 0, '7.0.0', '7.0.1', $log);
|
||||
|
||||
$this->assertTrue($result, 'discovered=0 이면 abort 모드에서도 정상 통과(true)');
|
||||
$this->assertStringContainsString('실행할 스텝 없음', implode("\n", $logs));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleSpawnExit_구버전_자식_discovered_null_은_레거시_fail_fast_판정을_유지한다(): void
|
||||
{
|
||||
// discovered 필드를 모르는 구버전 자식 — executed=0 + discovered=null + from<to.
|
||||
// 신버전 부모는 이 케이스를 기존(레거시) fail-fast 로 판정해야 한다 (안전 우선).
|
||||
config(['app.update.spawn_failure_mode' => 'abort']);
|
||||
|
||||
$command = $this->makeCommandWithDummyIo();
|
||||
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'handleSpawnExit');
|
||||
$method->setAccessible(true);
|
||||
|
||||
try {
|
||||
// stepsExecuted=0, stepsDiscovered=null (구버전 자식)
|
||||
$method->invoke($command, 0, null, 0, null, '7.0.0', '7.0.1', fn () => null);
|
||||
$this->fail('discovered=null + executed=0 + from<to 는 레거시 fail-fast 여야 한다');
|
||||
} catch (UpgradeHandoffException $e) {
|
||||
$this->assertStringContainsString('step 0건 실행', $e->reason);
|
||||
$this->assertStringNotContainsString('발견', $e->reason, 'discovered 미상이므로 발견 건수 표기 없음');
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function spawn_자식_범위내_스텝_파일이_없어_0건이면_fallback_모드도_정상_통과한다(): void
|
||||
{
|
||||
if (! function_exists('proc_open')) {
|
||||
$this->markTestSkipped('proc_open 미지원 환경');
|
||||
@@ -134,7 +210,7 @@ class CoreUpdateCommandSpawnFailureTest extends TestCase
|
||||
|
||||
$result = $method->invoke($command, '9.9.8', '9.9.9', true, fn () => null);
|
||||
|
||||
$this->assertFalse($result, 'mode=fallback 에서 step 0건 실행은 false 반환');
|
||||
$this->assertTrue($result, '스텝 파일 부재(discovered=0)는 fallback 모드에서도 정상 통과(true)');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* 라운드트립으로 런타임 동작 검증.
|
||||
* - 백엔드(OrderResponsePaymentHandlerTest.php, 6건 green): provider→응답
|
||||
* pg_payment_handler 매핑, 미선언 시 키 부재.
|
||||
* 브라우저 실측은 PG 샌드박스가 구성된 환경에서 PO 가 직접 수행한다.
|
||||
* 브라우저 실측은 PG 샌드박스가 구성된 환경에서 직접 수행한다.
|
||||
*
|
||||
* 활성화 전 사전 작업:
|
||||
* 1. checkout 결제하기 버튼에 data-testid="checkout-submit-order"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Layout Editor — 번들 분리 (lazy 로드) 검증. (engine-v1.51.0)
|
||||
*
|
||||
* 코어 번들(template-engine.min.js)에서 편집기 코드를 분리해 별도 layout-editor.min.js 로
|
||||
* 빌드하고, `/admin/layout-editor/*` 진입 시에만 런타임 <script> 주입으로 로드한다.
|
||||
*
|
||||
* 검증:
|
||||
* - 일반 admin 페이지(대시보드) 접속 → layout-editor.min.js 요청이 발생하지 않는다
|
||||
* (초기 접속 payload 축소의 핵심 — 회귀 시 편집기 코드가 다시 모든 페이지에 실린다)
|
||||
* - 편집기 URL 진입 → layout-editor.min.js 요청 발생 + 편집기 마운트(g7le-toolbar +
|
||||
* g7le-preview-frame). React 사본이 둘이면 "Invalid hook call" 로 마운트 자체가 크래시하므로,
|
||||
* 마운트 성공이 곧 단일 React 인스턴스 + 컨텍스트/싱글톤 동일성(shim __runtime 공유)을 증명한다.
|
||||
*
|
||||
* @scenario bundle_split_lazy_editor
|
||||
* @effects editor_bundle_absent_on_normal_page + editor_bundle_loaded_and_mounted_on_editor_route
|
||||
*/
|
||||
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
|
||||
|
||||
type PwPage = import('@playwright/test').Page;
|
||||
|
||||
const EDITOR_BUNDLE_RE = /layout-editor\.min\.js/;
|
||||
|
||||
/**
|
||||
* page 의 네트워크 요청 중 편집기 번들 요청 URL 을 수집하는 리스너를 설치한다.
|
||||
*
|
||||
* @param page Playwright page
|
||||
* @returns 수집된 편집기 번들 요청 URL 배열(참조 — 이후 계속 채워짐)
|
||||
*/
|
||||
function collectEditorBundleRequests(page: PwPage): string[] {
|
||||
const hits: string[] = [];
|
||||
page.on('request', (req) => {
|
||||
if (EDITOR_BUNDLE_RE.test(req.url())) {
|
||||
hits.push(req.url());
|
||||
}
|
||||
});
|
||||
return hits;
|
||||
}
|
||||
|
||||
test.describe('Layout Editor — 번들 분리 (lazy 로드)', () => {
|
||||
test('일반 admin 페이지는 layout-editor.min.js 를 요청하지 않는다', async ({ page }) => {
|
||||
const token = issueToken('core.templates.layouts.edit');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
const editorRequests = collectEditorBundleRequests(page);
|
||||
|
||||
await page.goto('/admin/dashboard');
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
|
||||
// 초기 접속 payload 에 편집기 번들 미포함 — 회귀 방지 핵심
|
||||
expect(editorRequests, '일반 페이지에서 편집기 번들이 로드됨(회귀)').toEqual([]);
|
||||
});
|
||||
|
||||
test('편집기 URL 진입 시 layout-editor.min.js 를 로드하고 마운트한다', async ({ page }) => {
|
||||
const token = issueToken('core.templates.layouts.edit');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
const editorRequests = collectEditorBundleRequests(page);
|
||||
|
||||
await page.goto('/admin/layout-editor/sirsoft-basic?route=%2F');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
// 편집기 셸 마운트 대기 — 마운트 성공 = 단일 React 인스턴스 + 컨텍스트 동일성 증명
|
||||
await page.waitForSelector('[data-testid="g7le-toolbar"]', { timeout: 30_000 });
|
||||
await page.waitForSelector('[data-testid="g7le-preview-frame"]', { timeout: 30_000 });
|
||||
|
||||
// 편집기 진입 시에는 lazy 번들이 실제로 로드됐어야 한다
|
||||
expect(editorRequests.length, '편집기 번들 요청 미발생').toBeGreaterThan(0);
|
||||
expect(editorRequests[0]).toMatch(EDITOR_BUNDLE_RE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* DevTools — 번들 분리 (lazy/debug 로드) 검증. (engine-v1.52.0)
|
||||
*
|
||||
* 개발자 진단도구(DevTools)는 디버그 모드에서만 쓰이므로 코어 번들에서 분리해 별도
|
||||
* devtools.min.js 로 빌드하고, `initDevToolsAPI()` 가 `isEnabled()` 참일 때만 런타임
|
||||
* <script> 주입으로 로드한다. 디버그 판정 = `G7Config.debug`(app.debug) 또는 서버 환경설정
|
||||
* `settings.advanced.debug_mode` 중 하나라도 참.
|
||||
*
|
||||
* 검증:
|
||||
* - 디버그 상태에 따라 devtools.min.js 로드 여부가 갈린다:
|
||||
* · 디버그 OFF → 요청 없음 (초기 접속 payload 에서 DevTools 611KB 소스 제거)
|
||||
* · 디버그 ON → 번들 로드 + 패널(#g7-devtools-root) 마운트. React 사본이 둘이면
|
||||
* 패널 마운트가 크래시하므로, 마운트 성공이 곧 shim 공유(G7DevToolsCore) + 단일 React 증명.
|
||||
*
|
||||
* 라이브 서버의 디버그 상태를 강제 전환하지 않고, 실제 상태를 감지해 해당 불변식만 단언한다.
|
||||
*
|
||||
* @scenario devtools_bundle_split_lazy
|
||||
* @effects devtools_bundle_lazy_by_debug_state
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
|
||||
type PwPage = import('@playwright/test').Page;
|
||||
|
||||
const DEVTOOLS_BUNDLE_RE = /devtools\.min\.js/;
|
||||
|
||||
function collectDevToolsRequests(page: PwPage): string[] {
|
||||
const hits: string[] = [];
|
||||
page.on('request', (req) => {
|
||||
if (DEVTOOLS_BUNDLE_RE.test(req.url())) {
|
||||
hits.push(req.url());
|
||||
}
|
||||
});
|
||||
return hits;
|
||||
}
|
||||
|
||||
test.describe('DevTools — 번들 분리 (lazy/debug 로드)', () => {
|
||||
test('디버그 상태에 따라 devtools.min.js 로드/미로드가 갈린다', async ({ page }) => {
|
||||
const devtoolsRequests = collectDevToolsRequests(page);
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
|
||||
// 페이지의 실제 디버그 활성 여부 감지
|
||||
const debugEnabled = await page.evaluate(() => {
|
||||
try {
|
||||
return Boolean((window as any).G7DevTools?.isEnabled?.());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (debugEnabled) {
|
||||
// 디버그 ON — lazy 번들 로드 + 패널 마운트(단일 React/shim 공유 증명)
|
||||
expect(devtoolsRequests.length, '디버그 ON 인데 DevTools 번들 미로드').toBeGreaterThan(0);
|
||||
expect(devtoolsRequests[0]).toMatch(DEVTOOLS_BUNDLE_RE);
|
||||
// 패널 컨테이너가 DOM 에 마운트됐는지 확인(attached — 접힘 상태라 visible 아님).
|
||||
// 마운트 성공 = React 사본 단일 + G7DevToolsCore shim 공유 정상의 증명.
|
||||
await page.waitForSelector('#g7-devtools-root', { state: 'attached', timeout: 15_000 });
|
||||
} else {
|
||||
// 디버그 OFF — 초기 payload 에서 DevTools 번들 제거(회귀 방지 핵심)
|
||||
expect(devtoolsRequests, '디버그 OFF 인데 DevTools 번들이 로드됨(회귀)').toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ use Tests\TestCase;
|
||||
/**
|
||||
* `FilePermissionHelper::copyDirectory` / `removeOrphanItems` 의 symlink 보존 동작 회귀 가드.
|
||||
*
|
||||
* 회귀 시나리오 (이슈 #28 + 본 계획 §1):
|
||||
* 회귀 시나리오 (gnuboard/g7#28):
|
||||
* - 코어 자동 롤백 시 `public/storage` symlink 가 target 디렉토리 내용으로 추적 복사되어
|
||||
* symlink 가 일반 디렉토리로 변질 → 운영자가 `storage:link` 재실행 필요
|
||||
* - `removeOrphanItems` 가 source 부재 symlink 를 디렉토리로 인식하여 target 의 모든 파일을
|
||||
@@ -134,4 +134,78 @@ class FilePermissionHelperSymlinkTest extends TestCase
|
||||
$this->assertSame('hi', File::get($dest.'/file.txt'));
|
||||
$this->assertSame('world', File::get($dest.'/sub/nested.txt'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows JUNCTION (`mklink /J`) 는 PHP 의 `SplFileInfo::isLink()` / `isDir()` 이
|
||||
* 모두 false 를 반환하는 reparse point 다. `php artisan storage:link` 가 Windows 에서
|
||||
* 생성하는 `public/storage` 가 이 형태이며, 이를 파일로 오판하면 `copyFile` 이
|
||||
* 디렉토리를 `copy()` 에 넘겨 `copy(): The first argument ... cannot be a directory` 로
|
||||
* 코어 업데이트가 실패한다 (회귀 가드).
|
||||
*
|
||||
* junction 은 Windows 전용이므로 비-Windows 환경에서는 skip.
|
||||
*/
|
||||
#[Test]
|
||||
public function copyDirectory_는_windows_junction_을_파일로_오판하지_않고_보존합니다(): void
|
||||
{
|
||||
if (PHP_OS_FAMILY !== 'Windows') {
|
||||
$this->markTestSkipped('junction 은 Windows 전용 (mklink /J)');
|
||||
}
|
||||
|
||||
$source = $this->tempRoot.'/src';
|
||||
$dest = $this->tempRoot.'/dst';
|
||||
$linkTarget = $this->tempRoot.'/link_target';
|
||||
|
||||
File::ensureDirectoryExists($source);
|
||||
File::ensureDirectoryExists($linkTarget);
|
||||
File::put($linkTarget.'/inside.txt', 'junction 대상 안 파일');
|
||||
|
||||
$junctionPath = $source.'/storage';
|
||||
// mklink /J 는 관리자 권한 없이 생성 가능 (심볼릭 링크와 달리 SeCreateSymbolicLink 불필요)
|
||||
exec('cmd /c mklink /J '.escapeshellarg($junctionPath).' '.escapeshellarg($linkTarget).' 2>&1', $out, $code);
|
||||
if ($code !== 0 || ! is_dir($junctionPath)) {
|
||||
$this->markTestSkipped('junction 생성 실패: '.implode("\n", $out));
|
||||
}
|
||||
|
||||
// 사전 조건: PHP 가 junction 을 link/dir 어느 쪽으로도 인식하지 못함을 확인
|
||||
$this->assertFalse(is_link($junctionPath), 'junction 은 is_link() 가 false (전제)');
|
||||
|
||||
// copyFile 이 디렉토리를 받아 던지던 예외 없이 완료되어야 한다
|
||||
FilePermissionHelper::copyDirectory($source, $dest);
|
||||
|
||||
// junction 은 dest 에도 junction 으로 보존되어 target 내용에 접근 가능해야 한다
|
||||
$this->assertTrue(is_dir($dest.'/storage'), 'dest 의 storage 는 디렉토리(junction)로 접근 가능해야 한다');
|
||||
$this->assertFileExists($dest.'/storage/inside.txt', 'junction 통과 시 target 파일 접근 가능');
|
||||
}
|
||||
|
||||
/**
|
||||
* removeOrphanItems 가 source 부재 junction 을 재귀 삭제(target 내용 삭제)하지 않고
|
||||
* 링크 자체만 제거하는지 검증 (junction 은 isDir() 이 false 라 별도 rmdir 경로 필요).
|
||||
*/
|
||||
#[Test]
|
||||
public function removeOrphanItems_는_source_부재_junction_을_target_추적_없이_링크만_제거합니다(): void
|
||||
{
|
||||
if (PHP_OS_FAMILY !== 'Windows') {
|
||||
$this->markTestSkipped('junction 은 Windows 전용 (mklink /J)');
|
||||
}
|
||||
|
||||
$source = $this->tempRoot.'/src';
|
||||
$dest = $this->tempRoot.'/dst';
|
||||
$linkTarget = $this->tempRoot.'/link_target';
|
||||
|
||||
File::ensureDirectoryExists($source);
|
||||
File::ensureDirectoryExists($dest);
|
||||
File::ensureDirectoryExists($linkTarget);
|
||||
File::put($linkTarget.'/precious.txt', '중요한 파일');
|
||||
|
||||
// dest 에만 존재하는 junction (source 에는 없음)
|
||||
exec('cmd /c mklink /J '.escapeshellarg($dest.'/orphan_link').' '.escapeshellarg($linkTarget).' 2>&1', $out, $code);
|
||||
if ($code !== 0 || ! is_dir($dest.'/orphan_link')) {
|
||||
$this->markTestSkipped('junction 생성 실패: '.implode("\n", $out));
|
||||
}
|
||||
|
||||
FilePermissionHelper::copyDirectory($source, $dest, null, [], '', true);
|
||||
|
||||
$this->assertDirectoryDoesNotExist($dest.'/orphan_link', 'orphan junction 은 제거되어야 한다');
|
||||
$this->assertFileExists($linkTarget.'/precious.txt', 'junction target 의 파일은 보존되어야 한다 (재귀 삭제 사고 차단)');
|
||||
}
|
||||
}
|
||||
@@ -15,19 +15,26 @@ description: |
|
||||
|
||||
구성 (정책 결정 2026-05-11):
|
||||
1. `spawn_failure_mode` 기본값 `abort` — 4분기 모두 UpgradeHandoffException throw.
|
||||
2. `[STEPS_EXECUTED]` stdout 라인 프로토콜 — 자식이 실행한 step 수 명시.
|
||||
부모가 신호 미수신 또는 step 0건 실행 (from<to) 감지 시 silent skip 으로 판정.
|
||||
2. `[STEPS_EXECUTED]` stdout 라인 프로토콜 — 자식이 실행한 step 수(count) + 범위 내
|
||||
발견된 스텝 파일 수(discovered) 명시. 부모는 신호 미수신 또는 executed=0 &&
|
||||
discovered>0 (스텝 파일이 있는데 실행 못함) 을 silent skip 으로 판정. executed=0 &&
|
||||
discovered=0 (스텝 파일 부재) 은 정상 통과 — from<to 여도 실패 아님 (스텝 불필요 릴리즈).
|
||||
3. `runUpgradeSteps` 진입 직후 stale 메모리 가드 — config('app.version') < toVersion
|
||||
이면 mode=abort 시 throw, mode=fallback 시 warning log.
|
||||
4. V-1 안전 작성 패턴 docs + audit rule `upgrade-step-vone-safety` (warning) —
|
||||
upgrade step 안의 `app(*Service|*Manager|*Repository::class)` 호출을 reviewer 에게 surface.
|
||||
|
||||
분기 개정 (discovered 축 세분화): `child_silent_skip_zero_steps` 를 discovered 축으로
|
||||
세분화. 범위 내 스텝 파일이 애초에 없어 0건인 경우(discovered=0)를 정상 통과로 재분류하여,
|
||||
스텝이 필요 없는 패치 릴리즈(예: 7.0.0→7.0.1) 를 실패로 오판하던 결함을 차단.
|
||||
|
||||
axes:
|
||||
spawn_branch: [proc_open_disabled, proc_open_resource_fail, child_abnormal_exit, child_silent_skip_no_signal, child_silent_skip_zero_steps]
|
||||
spawn_branch: [proc_open_disabled, proc_open_resource_fail, child_abnormal_exit, child_silent_skip_no_signal, child_zero_steps_with_discovered, child_zero_steps_no_step_files]
|
||||
spawn_failure_mode: [abort, fallback]
|
||||
from_to_relation: [from_lt_to, from_eq_to_forced] # version_compare 분기
|
||||
parent_memory_version: [equal_to_to, less_than_to, greater_than_to] # stale 가드 트리거 조건
|
||||
steps_executed_signal: [present_positive, present_zero, absent] # 자식의 [STEPS_EXECUTED] 발행 상태
|
||||
steps_executed_signal: [present_positive, present_zero, absent] # 자식의 [STEPS_EXECUTED] count 발행 상태
|
||||
steps_discovered_signal: [discovered_positive, discovered_zero, absent] # 범위 내 발견된 스텝 파일 수 (신버전 자식만 발행, 구버전=absent)
|
||||
child_process_origin: [beta_5_plus, beta_4_or_earlier] # 이전 버전 자식 (silent skip 시뮬레이션)
|
||||
upgrade_step_v_one_pattern: [app_service_call, app_manager_call, app_repository_call, local_only]
|
||||
audit_allow_inline: [present, absent] # 면제 주석 적용 여부
|
||||
@@ -35,8 +42,12 @@ axes:
|
||||
|
||||
exclusions:
|
||||
- { spawn_branch: proc_open_disabled, parent_memory_version: equal_to_to, reason: "proc_open 비활성 시점에 부모는 항상 fromVersion 메모리 — equal 케이스 시뮬 불가" }
|
||||
- { from_to_relation: from_eq_to_forced, spawn_branch: child_silent_skip_zero_steps, reason: "from==to + force 케이스는 step 0건이 정상 동작이므로 silent skip 가드 미트리거" }
|
||||
- { from_to_relation: from_eq_to_forced, spawn_branch: child_zero_steps_with_discovered, reason: "from==to + force 케이스는 step 0건이 정상 동작이므로 silent skip 가드 미트리거" }
|
||||
- { child_process_origin: beta_4_or_earlier, steps_executed_signal: present_positive, reason: "beta.5 이전 자식은 [STEPS_EXECUTED] 신호 자체를 모름 — present_positive 시뮬 불가" }
|
||||
- { child_process_origin: beta_4_or_earlier, steps_discovered_signal: discovered_positive, reason: "discovered 필드는 신버전 자식만 발행 — 구버전 자식은 항상 absent" }
|
||||
- { child_process_origin: beta_4_or_earlier, steps_discovered_signal: discovered_zero, reason: "동일 — 구버전 자식은 discovered 필드 미발행(absent)" }
|
||||
- { spawn_branch: child_zero_steps_no_step_files, steps_discovered_signal: discovered_positive, reason: "스텝 파일 부재 브랜치는 정의상 discovered=0 — positive 모순" }
|
||||
- { spawn_branch: child_zero_steps_with_discovered, steps_discovered_signal: discovered_zero, reason: "스텝 파일 존재 브랜치는 정의상 discovered>0 — zero 모순" }
|
||||
|
||||
effects:
|
||||
# §2 spawn_failure_mode + failSpawnWithMode
|
||||
@@ -45,12 +56,17 @@ effects:
|
||||
- spawnUpgradeStepsProcess_proc_open_disabled_dispatches_to_failSpawnWithMode
|
||||
- spawnUpgradeStepsProcess_proc_open_resource_fail_dispatches_to_failSpawnWithMode
|
||||
- spawnUpgradeStepsProcess_child_abnormal_exit_dispatches_to_failSpawnWithMode
|
||||
# §2.1 [STEPS_EXECUTED] silent skip 가드
|
||||
- ExecuteUpgradeStepsCommand_emits_STEPS_EXECUTED_with_count_on_normal_completion
|
||||
# §2.1 [STEPS_EXECUTED] silent skip 가드 (count + discovered)
|
||||
- ExecuteUpgradeStepsCommand_emits_STEPS_EXECUTED_with_count_and_discovered_on_normal_completion
|
||||
- ExecuteUpgradeStepsCommand_does_not_emit_STEPS_EXECUTED_on_handoff_exit
|
||||
- spawnUpgradeStepsProcess_parses_STEPS_EXECUTED_with_positive_count_returns_true
|
||||
- spawnUpgradeStepsProcess_missing_STEPS_EXECUTED_dispatches_to_failSpawnWithMode
|
||||
- spawnUpgradeStepsProcess_zero_steps_with_from_lt_to_dispatches_to_failSpawnWithMode
|
||||
# discovered 기반 분기 — executed=0 을 discovered 로 구분
|
||||
- handleSpawnExit_zero_executed_zero_discovered_returns_true # 케이스 B: 스텝 파일 부재 정상 통과
|
||||
- handleSpawnExit_zero_executed_positive_discovered_dispatches_to_failSpawnWithMode # 케이스 A: gnuboard/g7#28 silent skip
|
||||
- handleSpawnExit_zero_executed_null_discovered_from_lt_to_dispatches_to_failSpawnWithMode # 구버전 자식 레거시 판정
|
||||
- spawnUpgradeStepsProcess_no_step_files_returns_true_even_when_from_lt_to # 7.0.0→7.0.1 실증
|
||||
- runUpgradeSteps_notifies_discovered_zero_when_no_step_in_range
|
||||
- spawnUpgradeStepsProcess_zero_steps_with_from_eq_to_forced_returns_true
|
||||
# §6 stale 메모리 가드
|
||||
- runUpgradeSteps_throws_UpgradeHandoffException_when_memory_lt_to_with_abort_mode
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# audit:allow test-scenario-coverage reason: axes cross product 중 의미적으로 핵심인 격리/폴백/전체성공 케이스만 docblock 매핑. probe_item 축의 각 항목(os_info/cpu_info/memory_usage/disk_usage 등)은 모두 동일한 safeSystemProbe 격리 경로를 타므로 cpu_info 1건으로 대표 검증(나머지는 형식적 확장). E2E axis 없음 — 순수 백엔드 JSON API 로 레이아웃/프론트 렌더 변경 없음.
|
||||
|
||||
feature: 관리자 시스템 정보(system-info) probe 실패 격리
|
||||
|
||||
description: |
|
||||
`GET /api/admin/settings/system-info` 는 OS·CPU·메모리·디스크·PHP 확장·DB 등
|
||||
여러 시스템 정보를 수집한다. 이 중 일부 probe (php_uname / shell_exec / disk_*_space
|
||||
등) 는 호스팅의 disable_functions / open_basedir 제약으로 예외·Error 를 던질 수 있다.
|
||||
|
||||
SettingsService::safeSystemProbe 가 각 항목 수집을 개별 격리하여, 한 항목의 실패가
|
||||
전체 API 를 500 으로 떨어뜨리지 않고 해당 항목만 'unknown'(사용량 배열은 percentage 0)
|
||||
폴백으로 채우도록 한다. 개별 실패는 Log::warning, 격리를 뚫은 전체 실패는 컨트롤러
|
||||
Log::error 로 기록된다.
|
||||
|
||||
공개 이슈 gnuboard/g7#59 의 회귀 가드 — 공개 #40(대시보드 리소스 API) 격리가
|
||||
DashboardService 에만 적용되고 SettingsService::buildSystemInfo 에는 disk 한 곳만
|
||||
부분 보강되어, CPU/메모리 등 다른 probe 로 재발한 결함의 재발 차단.
|
||||
|
||||
axes:
|
||||
probe_item: [cpu_info, os_info, memory_usage, disk_usage, php_memory_limit, mysql_version]
|
||||
probe_outcome: [success, throws_error, throws_exception]
|
||||
auth_state: [authenticated_with_permission, authenticated_without_permission, unauthenticated]
|
||||
|
||||
exclusions:
|
||||
- { probe_item: os_info, probe_outcome: throws_exception, reason: "각 probe_item 은 동일 safeSystemProbe 경로 — cpu_info 대표 검증으로 커버, 나머지는 형식적 확장" }
|
||||
- { auth_state: unauthenticated, probe_outcome: throws_error, reason: "인증 가드가 probe 실행 전에 401/403 으로 차단 — probe_outcome 과 무관" }
|
||||
|
||||
effects:
|
||||
- failing_probe_isolated_and_api_returns_200
|
||||
- failed_item_filled_with_unknown_fallback
|
||||
- usage_array_fallback_keeps_total_used_free_unknown_and_percentage_zero
|
||||
- all_probes_success_returns_full_payload
|
||||
- unauthenticated_returns_401
|
||||
- without_permission_returns_403
|
||||
|
||||
# E2E axis 없음: system-info 는 순수 백엔드 JSON API. 레이아웃 JSON / 컴포넌트 TSX /
|
||||
# 엔진 TS / 핸들러 변경이 없으므로 Playwright spec 동반 의무(frontend-change-requires-e2e)
|
||||
# 대상 아님. 격리 검증의 본체는 Feature/Unit 테스트가 담당.
|
||||
|
||||
test_files:
|
||||
- tests/Feature/Api/Admin/SettingsControllerTest.php
|
||||
@@ -0,0 +1,88 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* DevTools lazy 번들 빌드 설정 (engine-v1.52.0)
|
||||
*
|
||||
* 개발자 진단도구(패널 UI/진단엔진/서버커넥터/스타일추적기)는 디버그 모드에서만 쓰이므로
|
||||
* 메인 코어 번들에서 분리해 별도 `devtools.min.js` 로 빌드한다. `initDevToolsAPI()` 가
|
||||
* `isEnabled()` 참일 때만 런타임 <script> 주입으로 로드된다.
|
||||
*
|
||||
* 핵심:
|
||||
* - React/ReactDOM/jsx-runtime = external → window.React 등 단일 인스턴스 재사용.
|
||||
* - G7DevToolsCore(추적 코어, 항상 메인 번들에 상주) = resolveId 플러그인으로 shim 치환 →
|
||||
* window.G7Core.__runtime.G7DevToolsCore 공유(싱글톤 동일성 보장).
|
||||
*/
|
||||
|
||||
const coreDir = path.resolve(__dirname, 'resources/js/core');
|
||||
const devtoolsDir = path.resolve(coreDir, 'devtools');
|
||||
const shimDir = path.resolve(devtoolsDir, '__runtime-shims');
|
||||
|
||||
// 원본 절대경로(확장자 제외) → shim 절대경로
|
||||
const targetToShim = new Map([
|
||||
[
|
||||
path.resolve(devtoolsDir, 'G7DevToolsCore').split(path.sep).join('/'),
|
||||
path.resolve(shimDir, 'G7DevToolsCore.ts').split(path.sep).join('/'),
|
||||
],
|
||||
]);
|
||||
|
||||
function normalizeNoExt(id) {
|
||||
return id.replace(/\.(tsx?|jsx?)$/, '').split(path.sep).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* G7DevToolsCore import 를 __runtime-shims 로 리디렉트하는 Vite 플러그인.
|
||||
* 편집기 번들과 동일 방식(enforce:'pre' + importer 기준 절대경로 매칭).
|
||||
*/
|
||||
function devtoolsRuntimeShimPlugin() {
|
||||
return {
|
||||
name: 'g7-devtools-runtime-shim',
|
||||
enforce: 'pre',
|
||||
resolveId(source, importer) {
|
||||
if (!importer) return null;
|
||||
if (normalizeNoExt(importer).includes('/__runtime-shims/')) return null;
|
||||
if (!source.startsWith('.')) return null;
|
||||
|
||||
const abs = normalizeNoExt(path.resolve(path.dirname(importer), source));
|
||||
return targetToShim.get(abs) || null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [devtoolsRuntimeShimPlugin(), react()],
|
||||
publicDir: false,
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
},
|
||||
build: {
|
||||
outDir: 'public/build/core',
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: path.resolve(coreDir, 'devtools-entry.ts'),
|
||||
name: 'G7DevToolsBundle',
|
||||
formats: ['iife'],
|
||||
fileName: () => 'devtools.min.js',
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'],
|
||||
output: {
|
||||
globals: {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
'react-dom/client': 'ReactDOM',
|
||||
'react/jsx-runtime': 'ReactJSXRuntime',
|
||||
},
|
||||
exports: 'named',
|
||||
extend: true,
|
||||
},
|
||||
},
|
||||
minify: 'esbuild',
|
||||
sourcemap: true,
|
||||
target: 'es2020',
|
||||
},
|
||||
resolve: {
|
||||
alias: [{ find: '@', replacement: path.resolve(__dirname, 'resources/js') }],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* 레이아웃 편집기 lazy 번들 빌드 설정 (engine-v1.51.0)
|
||||
*
|
||||
* 메인 코어 번들(`vite.config.core.js` → template-engine.min.js)에서 편집기 코드를 제거해
|
||||
* 초기 접속 payload 를 줄이고, 편집기 셸을 별도 `layout-editor.min.js` 로 빌드한다.
|
||||
* `/admin/layout-editor/*` 진입 시에만 런타임 `<script>` 주입으로 로드된다.
|
||||
*
|
||||
* 핵심:
|
||||
* - React/ReactDOM/jsx-runtime = external → window.React 등 (메인 번들이 노출한 단일 인스턴스
|
||||
* 재사용). react/jsx-runtime 을 external 하지 않으면 @vitejs/plugin-react 의 automatic-runtime
|
||||
* JSX import 로 React 가 transitive 재포함돼 "Invalid hook call" 발생.
|
||||
* - 코어 런타임(DynamicRenderer/엔진/컨텍스트) = 커스텀 resolveId 플러그인으로 __runtime-shims
|
||||
* 치환 → window.G7Core.__runtime 에서 빌려 씀(재번들 0바이트, 싱글톤/컨텍스트 동일성 보장).
|
||||
*/
|
||||
|
||||
const coreDir = path.resolve(__dirname, 'resources/js/core');
|
||||
const shimDir = path.resolve(coreDir, 'template-engine/layout-editor/__runtime-shims');
|
||||
|
||||
// 편집기가 layout-editor/ 밖으로 import 하는 코어 런타임 모듈(확장자 제외 절대경로) → shim 절대경로.
|
||||
// resolveId 훅이 "해석된 절대경로" 기준으로 매칭하므로, 편집기가 어느 상대 깊이(../ ../../ ../../../)로
|
||||
// import 하든 단일 규칙으로 전부 shim 으로 치환된다.
|
||||
const RUNTIME_TARGETS = [
|
||||
['template-engine/DynamicRenderer', 'DynamicRenderer'],
|
||||
['template-engine/TranslationEngine', 'TranslationEngine'],
|
||||
['template-engine/TranslationContext', 'TranslationContext'],
|
||||
['template-engine/DataSourceManager', 'DataSourceManager'],
|
||||
['template-engine/DataBindingEngine', 'DataBindingEngine'],
|
||||
['template-engine/ActionDispatcher', 'ActionDispatcher'],
|
||||
['template-engine/ResponsiveManager', 'ResponsiveManager'],
|
||||
['template-engine/ResponsiveContext', 'ResponsiveContext'],
|
||||
['template-engine/ComponentRegistry', 'ComponentRegistry'],
|
||||
['utils/Logger', 'Logger'],
|
||||
['auth/AuthManager', 'AuthManager'],
|
||||
];
|
||||
|
||||
/** 절대경로(확장자 제거) → shim 절대경로 맵 */
|
||||
const targetToShim = new Map(
|
||||
RUNTIME_TARGETS.map(([target, shim]) => [
|
||||
path.resolve(coreDir, target).split(path.sep).join('/'),
|
||||
path.resolve(shimDir, shim + '.ts').split(path.sep).join('/'),
|
||||
])
|
||||
);
|
||||
|
||||
/** 경로에서 .ts/.tsx/.js/.jsx 확장자 제거 후 정규화(/구분자) */
|
||||
function normalizeNoExt(id) {
|
||||
return id.replace(/\.(tsx?|jsx?)$/, '').split(path.sep).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 런타임 import 를 __runtime-shims 로 리디렉트하는 Vite 플러그인.
|
||||
*
|
||||
* Vite 의 문자열 `resolve.alias` 는 import specifier(상대경로 원문) 기준 매칭이라 상대 깊이가
|
||||
* 제각각인 편집기 import 를 잡지 못한다. 따라서 기본 해석기가 계산한 "절대경로"를 기준으로
|
||||
* 매칭하는 resolveId 훅을 쓴다.
|
||||
*
|
||||
* shim 파일 자신이 원본 경로를 import(값은 없고 `export type` 만 — 컴파일 시 소거)할 수 있으므로,
|
||||
* importer 가 shim 디렉토리 내부면 리디렉트하지 않아 무한 루프를 막는다.
|
||||
*/
|
||||
function coreRuntimeShimPlugin() {
|
||||
return {
|
||||
name: 'g7-core-runtime-shim',
|
||||
enforce: 'pre', // 기본 해석기보다 먼저 실행되어 코어 런타임 import 를 가로챈다
|
||||
async resolveId(source, importer, options) {
|
||||
if (!importer) return null;
|
||||
// shim 자신이 원본을 import(type-only)하는 경우는 리디렉트 제외 (루프 방지)
|
||||
if (normalizeNoExt(importer).includes('/__runtime-shims/')) {
|
||||
return null;
|
||||
}
|
||||
// 상대경로 import 만 대상 (코어 런타임은 편집기에서 상대경로로 참조됨)
|
||||
if (!source.startsWith('.')) return null;
|
||||
|
||||
// importer 기준으로 절대경로 계산 (this.resolve 미사용 — enforce:pre 재진입 회피)
|
||||
const abs = normalizeNoExt(path.resolve(path.dirname(importer), source));
|
||||
const shim = targetToShim.get(abs);
|
||||
return shim || null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [coreRuntimeShimPlugin(), react()],
|
||||
publicDir: false,
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
},
|
||||
build: {
|
||||
outDir: 'public/build/core',
|
||||
emptyOutDir: false, // template-engine.min.js 와 같은 디렉토리 공유
|
||||
lib: {
|
||||
entry: path.resolve(coreDir, 'layout-editor-entry.ts'),
|
||||
name: 'G7LayoutEditor', // G7Core 와 구분 (실제 연동은 window.G7Core.__LayoutEditorChrome)
|
||||
formats: ['iife'],
|
||||
fileName: () => 'layout-editor.min.js',
|
||||
},
|
||||
rollupOptions: {
|
||||
// React 3종 + jsx-runtime = 메인 번들의 window 전역 재사용 (단일 인스턴스)
|
||||
external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'],
|
||||
output: {
|
||||
globals: {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
'react-dom/client': 'ReactDOM',
|
||||
'react/jsx-runtime': 'ReactJSXRuntime',
|
||||
},
|
||||
exports: 'named',
|
||||
extend: true,
|
||||
},
|
||||
},
|
||||
minify: 'esbuild',
|
||||
sourcemap: true,
|
||||
target: 'es2020',
|
||||
},
|
||||
resolve: {
|
||||
alias: [{ find: '@', replacement: path.resolve(__dirname, 'resources/js') }],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user