fix(engine): 페이지 로드 요청 1건의 일시 실패가 앱 전체를 죽이는 문제
모바일에서 새로고침을 연타하면 진행 중이던 요청이 취소되는데, 화면 구성에 필요한 요청 하나만 그렇게 끊겨도 전면 "초기화 실패" 로 끝났다. 재시도가 전혀 없었고, 로컬은 왕복이 수 ms 라 재현되지 않아 오래 남아 있었다. fetch 는 4xx/5xx 로 reject 하지 않고 Response.ok=false 로 resolve 한다. 따라서 TypeError 는 "응답 자체가 없었다"(취소·커넥션 유실)는 뜻이며, 그 경우만 재시도해야 한다. HTTP 응답은 그대로 호출부에 넘겨 기존 상태코드 분기와 이중으로 겹치지 않게 했다. - routes.json / components.json / 레이아웃 JSON: 네트워크 실패에만 지수 백오프 재시도 - 실패한 promise 가 레이아웃 캐시에 남아 영구 실패가 되던 문제 수정 - 문서 이탈(pagehide) 중에는 에러 화면을 그리지 않는다 (버려질 문서) - 확장 에셋 로더의 실패 계약을 resolve → reject 로 (실패를 발생 지점에서 표면화) - 번들 로드가 실패로 확정되면 오지 않을 핸들러를 5초간 기다리지 않는다 - 미등록 핸들러의 raw 내부 식별자가 토스트로 노출되던 것을 차단 - 번들이 끝내 부재해도 백지 대신 새로고침 버튼이 있는 안내 화면 정상 경로 성능은 종전과 같다. script 태그는 정적으로 유지해 프리로드 스캐너 이점을 보존하고, 실패했을 때만 onerror 에서 동적 재시도를 건다.
This commit is contained in:
@@ -8,6 +8,9 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 모바일에서 새로고침을 연속으로 눌렀을 때 간헐적으로 "초기화 실패" 또는 "페이지 로딩 실패" 화면이 뜨던 문제를 수정했습니다. 화면을 그리는 데 필요한 요청 하나가 회선 사정으로 잠깐 끊기면 그것만으로 페이지 전체가 뜨지 않았습니다. 이제 이런 일시적인 끊김은 자동으로 다시 시도해 복구하므로, 대부분의 경우 사용자는 아무것도 느끼지 못한 채 화면이 정상적으로 열립니다.
|
||||
- 화면 구성에 필요한 스크립트를 끝내 불러오지 못한 경우, 아무 안내 없이 빈 화면만 남거나 영문 오류 문구가 그대로 노출되던 문제를 수정했습니다. 이제 무슨 일이 있었는지 알려주는 안내와 새로고침 버튼이 표시됩니다. 관리자 화면에도 동일하게 적용됩니다.
|
||||
- 쇼핑몰 등 확장 기능을 불러오지 못한 상태에서 화면이 5초가량 빈 채로 멈춰 있다가 뜨던 문제를 수정했습니다. 이제 지체 없이 화면이 열리고, 불러오지 못한 확장 기능만 조용히 빠집니다.
|
||||
- 외부 라이브러리를 쓰는 확장의 버전을 올린 뒤 그 확장을 업데이트하면 "번들 파일의 무결성 검증에 실패했습니다"라는 오류로 업데이트가 막히던 문제를 수정했습니다. 오류를 풀려면 업데이트가 필요한데 그 업데이트 자체가 막혀 빠져나올 수 없는 상태였습니다. 아울러 확장 버전만 올린 경우에도 번들 재빌드가 필요함을 `--check` 점검이 올바르게 알려주도록 바로잡았습니다. (이미 배포된 확장을 내려받아 쓰는 환경에는 영향이 없으며, 확장을 직접 개발·수정하는 경우에 해당합니다.)
|
||||
- Windows 개발 환경에서 코어 의존성 번들을 다시 만들 때, 마지막 정리 단계에서 진행이 멈춘 채 끝나지 않던 문제를 수정했습니다. 이제 번들 빌드가 정상적으로 완료됩니다. 아울러 빌드가 중간에 실패하더라도 기존 번들 파일이 손상되지 않도록 새 결과가 완성된 뒤에만 교체하도록 개선했습니다. (확장을 직접 개발·수정하는 환경에 해당하며, 배포본을 내려받아 쓰는 환경에는 영향이 없습니다.)
|
||||
- 확장에 외부 라이브러리를 새로 추가한 뒤 번들을 만들면, 새로 추가한 라이브러리가 번들에 담기지 않은 채로 만들어지던 문제를 수정했습니다. 검증은 통과하지만 실제 파일이 빠져 있어 설치 후 실행 중에 오류가 나던 상태였습니다. 이제 새로 추가한 라이브러리가 번들에 정상적으로 포함됩니다. (확장을 직접 개발·수정하는 환경에 해당합니다.)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
### Added
|
||||
|
||||
- 화면 구성에 필요한 스크립트를 끝내 불러오지 못했을 때 표시되는 안내 문구 일본어 번역 추가 (`errors.bootstrap.*`) — 네트워크 문제로 페이지를 열지 못한 경우의 안내와 새로고침 버튼이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- vendor-bundle 빌드 결과 반영 실패 안내 메시지 일본어 번역 추가 (`exceptions.vendor.bundle_build_promote_failed`) — 빌드 산출물을 최종 경로로 원자적 교체하는 데 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
## [1.0.2] - 2026-07-08
|
||||
|
||||
@@ -27,4 +27,9 @@ return [
|
||||
'contact_admin' => '問題が解決しない場合は、管理者にお問い合わせください。',
|
||||
],
|
||||
'back_home' => 'ホームに戻る',
|
||||
'bootstrap' => [
|
||||
'title' => '画面を読み込めませんでした',
|
||||
'message' => 'ネットワーク接続が不安定な可能性があります。更新後にもう一度お試しください。',
|
||||
'reload' => '更新',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -38,4 +38,11 @@ return [
|
||||
'plugins' => 'Plugins',
|
||||
'contact_admin' => 'If the problem persists, please contact the administrator.',
|
||||
],
|
||||
|
||||
// 화면 구성에 필요한 스크립트를 끝내 불러오지 못했을 때 (코어 엔진 / 템플릿 컴포넌트 번들)
|
||||
'bootstrap' => [
|
||||
'title' => 'Failed to load the page',
|
||||
'message' => 'Your network connection may be unstable. Please refresh and try again.',
|
||||
'reload' => 'Refresh',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -38,4 +38,11 @@ return [
|
||||
'plugins' => '플러그인',
|
||||
'contact_admin' => '문제가 지속되면 관리자에게 문의해 주세요.',
|
||||
],
|
||||
|
||||
// 화면 구성에 필요한 스크립트를 끝내 불러오지 못했을 때 (코어 엔진 / 템플릿 컴포넌트 번들)
|
||||
'bootstrap' => [
|
||||
'title' => '화면을 불러오지 못했습니다',
|
||||
'message' => '네트워크 연결이 불안정할 수 있습니다. 새로고침 후 다시 시도해 주세요.',
|
||||
'reload' => '새로고침',
|
||||
],
|
||||
];
|
||||
|
||||
+16
-16
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -28,6 +28,7 @@ import { createLogger, Logger } from './utils/Logger';
|
||||
import { webSocketManager } from './websocket/WebSocketManager';
|
||||
import { getModuleAssetLoader, parseModuleAssetsFromConfig, parsePluginAssetsFromConfig, parseBundleUrlsFromConfig } from './modules';
|
||||
import { SystemBannerManager } from './template-engine/SystemBannerManager';
|
||||
import { fetchWithRetry, installUnloadGuard, isDocumentUnloading } from './template-engine/networkResilience';
|
||||
import { resetLocalInitTracking } from './template-engine/localInitSlot';
|
||||
/**
|
||||
* DevTools 추적 - G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다.
|
||||
@@ -443,6 +444,10 @@ export class TemplateApp {
|
||||
Logger.getInstance().setDebug(this.config.debug);
|
||||
logger.log('Initializing with config:', this.config);
|
||||
|
||||
// 문서 이탈 감지 가드 설치 — 새로고침으로 버려지는 문서의 요청 실패에
|
||||
// 에러 화면을 그리지 않기 위함 (@since engine-v1.53.0)
|
||||
installUnloadGuard();
|
||||
|
||||
// 1. 템플릿 엔진 초기화, ComponentRegistry, routes.json, 사용자 정보를 병렬 로딩
|
||||
const componentRegistry = ComponentRegistry.getInstance();
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -465,7 +470,11 @@ export class TemplateApp {
|
||||
this.config.templateType
|
||||
),
|
||||
// routes.json 로딩 (저장된 캐시 버전 사용)
|
||||
fetch(`/api/templates/${this.config.templateId}/routes.json${storedCacheVersion > 0 ? `?v=${storedCacheVersion}` : ''}`)
|
||||
// 네트워크 일시 실패(응답 없음)에만 재시도한다. HTTP 에러는 아래 체인이 종전대로 throw.
|
||||
fetchWithRetry(
|
||||
`/api/templates/${this.config.templateId}/routes.json${storedCacheVersion > 0 ? `?v=${storedCacheVersion}` : ''}`,
|
||||
{ label: 'routes.json' }
|
||||
)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load routes: ${response.statusText}`);
|
||||
@@ -522,7 +531,10 @@ export class TemplateApp {
|
||||
if (previousVersion !== null && previousVersion !== this.extensionCacheVersion) {
|
||||
logger.log('Cache version changed, reloading routes...');
|
||||
// routes.json을 새 캐시 버전으로 다시 로드
|
||||
const newRoutesData = await fetch(`/api/templates/${this.config.templateId}/routes.json?v=${this.extensionCacheVersion}`)
|
||||
const newRoutesData = await fetchWithRetry(
|
||||
`/api/templates/${this.config.templateId}/routes.json?v=${this.extensionCacheVersion}`,
|
||||
{ label: 'routes.json (reload)' }
|
||||
)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to reload routes: ${response.statusText}`);
|
||||
@@ -2088,8 +2100,21 @@ export class TemplateApp {
|
||||
|
||||
/**
|
||||
* 초기화 에러 화면 표시
|
||||
*
|
||||
* 문서 이탈 중(새로고침으로 버려지는 문서)에는 렌더하지 않는다. 사용자가 이미
|
||||
* 떠난 화면에 에러를 그려봐야 다음 문서가 그 위를 덮을 뿐이고, 새로고침 연타 시
|
||||
* "초기화 실패" 가 번쩍이는 원인이 된다.
|
||||
*
|
||||
* @param error 초기화 중 발생한 에러
|
||||
* @return void
|
||||
* @since engine-v1.53.0 (이탈 가드 추가)
|
||||
*/
|
||||
private showInitError(error: Error): void {
|
||||
if (isDocumentUnloading()) {
|
||||
logger.warn('Init failed while document is unloading — skipping error screen', error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Error를 TemplateEngineError로 변환
|
||||
const templateError = toTemplateEngineError(error);
|
||||
|
||||
@@ -4133,9 +4158,15 @@ export class TemplateApp {
|
||||
*
|
||||
* 모듈/플러그인 핸들러는 에셋 로드 후에 등록되므로, init_actions 실행 전에 대기합니다.
|
||||
*
|
||||
* 확장 번들 로드가 이미 **실패로 확정**된 경우에는 기다리지 않고 즉시 반환한다.
|
||||
* 그 확장의 핸들러는 영원히 등록되지 않으므로, `maxWait` 만큼 폴링하는 것은 순수한
|
||||
* 낭비이며 그 시간 동안 렌더가 시작되지 않아 사용자에게는 백지로 보인다.
|
||||
* (@since engine-v1.53.0)
|
||||
*
|
||||
* @param actionDispatcher ActionDispatcher 인스턴스
|
||||
* @param handlerNames 대기할 핸들러 이름 목록
|
||||
* @param maxWait 최대 대기 시간 (ms)
|
||||
* @return Promise<void>
|
||||
*/
|
||||
private async waitForHandlers(
|
||||
actionDispatcher: any,
|
||||
@@ -4157,6 +4188,16 @@ export class TemplateApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// 확장 JS 번들 로드가 실패로 확정됐다면 그 핸들러는 영원히 오지 않는다.
|
||||
// 오지 않을 것을 기다리지 않는다 (5초 백지 제거).
|
||||
if (getModuleAssetLoader().hasFailedJsAssets()) {
|
||||
logger.warn(
|
||||
'Extension asset load failed — not waiting for handlers that will never register:',
|
||||
handlerNames
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('Waiting for module handlers:', handlerNames);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* TemplateApp 네트워크 복원력 테스트 (#463)
|
||||
*
|
||||
* 페이지 로드 요청 1건의 일시 실패가 앱 전체를 죽이던 결함의 회귀를 막는다.
|
||||
*
|
||||
* 검증 대상:
|
||||
* - routes.json / components.json 의 네트워크 실패 재시도 (HTTP 에러는 재시도 안 함)
|
||||
* - 문서 이탈(pagehide) 중 실패 시 에러 화면 미렌더 + bfcache 복귀 시 가드 해제
|
||||
* - 확장 번들 실패 확정 시 waitForHandlers 가 5초 블로킹하지 않음 (백지 제거)
|
||||
* - 미등록 핸들러 오류가 사용자 대상 표시 계층으로 나가지 않음 (raw 영문 노출 제거)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { TemplateApp } from '../TemplateApp';
|
||||
import type { TemplateAppConfig } from '../TemplateApp';
|
||||
import { resetUnloadGuardForTesting } from '../template-engine/networkResilience';
|
||||
import { ErrorDisplay } from '../template-engine/ErrorDisplay';
|
||||
|
||||
const mockApiClient = {
|
||||
post: vi.fn().mockResolvedValue({}),
|
||||
get: vi.fn().mockResolvedValue({}),
|
||||
removeToken: vi.fn(),
|
||||
setToken: vi.fn(),
|
||||
getToken: vi.fn().mockReturnValue(null),
|
||||
setOnUnauthorized: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../api/ApiClient', () => ({
|
||||
getApiClient: () => mockApiClient,
|
||||
}));
|
||||
|
||||
const { sharedActionDispatcher } = vi.hoisted(() => ({
|
||||
sharedActionDispatcher: {
|
||||
setNavigate: vi.fn(),
|
||||
setGlobalState: vi.fn(),
|
||||
setDefaultContext: vi.fn(),
|
||||
setGlobalStateUpdater: vi.fn(),
|
||||
registerHandler: vi.fn(),
|
||||
createHandler: vi.fn(() => vi.fn()),
|
||||
customHandlers: new Map<string, unknown>(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../template-engine', () => ({
|
||||
initTemplateEngine: vi.fn().mockResolvedValue(undefined),
|
||||
renderTemplate: vi.fn().mockResolvedValue(undefined),
|
||||
destroyTemplate: vi.fn(),
|
||||
updateTemplateData: vi.fn(),
|
||||
getActionDispatcher: vi.fn().mockReturnValue(sharedActionDispatcher),
|
||||
getState: vi.fn().mockReturnValue({
|
||||
actionDispatcher: sharedActionDispatcher,
|
||||
reactRoot: null,
|
||||
currentLayoutJson: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../routing/Router', () => ({
|
||||
Router: vi.fn(function (this: any) {
|
||||
this.loadRoutes = vi.fn().mockResolvedValue(undefined);
|
||||
this.setRoutes = vi.fn();
|
||||
this.on = vi.fn();
|
||||
this.navigateToCurrentPath = vi.fn();
|
||||
this.getRoutes = vi.fn().mockReturnValue([]);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../template-engine/ComponentRegistry', () => {
|
||||
const mockInstance = {
|
||||
loadComponents: vi.fn().mockResolvedValue(undefined),
|
||||
getComponent: vi.fn().mockReturnValue(() => null),
|
||||
hasComponent: vi.fn().mockReturnValue(true),
|
||||
getInstance: vi.fn(),
|
||||
};
|
||||
mockInstance.getInstance.mockReturnValue(mockInstance);
|
||||
return {
|
||||
ComponentRegistry: { getInstance: vi.fn(() => mockInstance) },
|
||||
};
|
||||
});
|
||||
|
||||
/** fetch 스펙상 네트워크 오류 = TypeError (응답 자체가 없음) */
|
||||
function networkError(): TypeError {
|
||||
return new TypeError('Failed to fetch');
|
||||
}
|
||||
|
||||
/** routes.json 정상 응답 */
|
||||
function routesOk(): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true, data: { routes: [] } }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
/** config.json 정상 응답 (선택적 — null 폴백 경로) */
|
||||
function configOk(): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true, data: {} }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function makeConfig(): TemplateAppConfig {
|
||||
return {
|
||||
templateId: 'sirsoft-basic',
|
||||
templateType: 'user',
|
||||
locale: 'ko',
|
||||
debug: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TemplateApp 네트워크 복원력 (#463)', () => {
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
resetUnloadGuardForTesting();
|
||||
vi.clearAllMocks();
|
||||
sharedActionDispatcher.customHandlers.clear();
|
||||
|
||||
errorSpy = vi
|
||||
.spyOn(ErrorDisplay, 'renderFromError')
|
||||
.mockImplementation(() => undefined as any);
|
||||
|
||||
(window as any).G7Config = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
resetUnloadGuardForTesting();
|
||||
delete (window as any).G7Config;
|
||||
});
|
||||
|
||||
describe('초기화 fetch 재시도', () => {
|
||||
it('routes.json 이 1회 네트워크 실패 후 성공하면 에러 화면을 렌더하지 않는다', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('routes.json')) {
|
||||
// 첫 호출만 실패
|
||||
const calls = fetchMock.mock.calls.filter((c: any[]) =>
|
||||
String(c[0]).includes('routes.json')
|
||||
).length;
|
||||
return calls === 1 ? Promise.reject(networkError()) : Promise.resolve(routesOk());
|
||||
}
|
||||
return Promise.resolve(configOk());
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const app = new TemplateApp(makeConfig());
|
||||
await app.init();
|
||||
|
||||
// 재시도로 복구 → 에러 화면 없음
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
|
||||
const routesCalls = fetchMock.mock.calls.filter((c: any[]) =>
|
||||
String(c[0]).includes('routes.json')
|
||||
);
|
||||
expect(routesCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('routes.json 이 3회 연속 네트워크 실패하면 에러 화면을 1회 렌더한다', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('routes.json')) {
|
||||
return Promise.reject(networkError());
|
||||
}
|
||||
return Promise.resolve(configOk());
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const app = new TemplateApp(makeConfig());
|
||||
await app.init();
|
||||
|
||||
// 재시도를 소진한 진짜 실패는 사용자에게 알려야 한다
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 상한(총 3시도)을 지킨다 — 무한루프 부재
|
||||
const routesCalls = fetchMock.mock.calls.filter((c: any[]) =>
|
||||
String(c[0]).includes('routes.json')
|
||||
);
|
||||
expect(routesCalls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('components.json 이 1회 네트워크 실패 후 성공하면 앱이 생존한다', async () => {
|
||||
// ComponentRegistry 는 vi.mock 으로 대체돼 있으므로, 매니페스트 fetch 재시도는
|
||||
// ComponentRegistry 자체 경로에서 검증된다. 여기서는 초기화 전체가 살아남는지 본다.
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('components.json')) {
|
||||
const calls = fetchMock.mock.calls.filter((c: any[]) =>
|
||||
String(c[0]).includes('components.json')
|
||||
).length;
|
||||
return calls === 1 ? Promise.reject(networkError()) : Promise.resolve(configOk());
|
||||
}
|
||||
if (url.includes('routes.json')) return Promise.resolve(routesOk());
|
||||
return Promise.resolve(configOk());
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const app = new TemplateApp(makeConfig());
|
||||
await app.init();
|
||||
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes.json 이 HTTP 500 이면 재시도 없이 즉시 에러 화면을 띄운다 (기존 동작 보존)', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('routes.json')) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
} as unknown as Response);
|
||||
}
|
||||
return Promise.resolve(configOk());
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const app = new TemplateApp(makeConfig());
|
||||
await app.init();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// HTTP 응답은 재시도 대상이 아니다 (fetch 는 4xx/5xx 로 reject 하지 않는다)
|
||||
const routesCalls = fetchMock.mock.calls.filter((c: any[]) =>
|
||||
String(c[0]).includes('routes.json')
|
||||
);
|
||||
expect(routesCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('문서 이탈 가드', () => {
|
||||
it('pagehide 이후 초기화가 실패하면 에러 화면을 렌더하지 않는다', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(networkError());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const app = new TemplateApp(makeConfig());
|
||||
|
||||
// init 이 unload guard 를 설치하므로, 설치 후 이탈을 발생시킨다
|
||||
const initPromise = app.init();
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
await initPromise;
|
||||
|
||||
// 버려지는 문서에 "초기화 실패" 를 그리지 않는다
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('bfcache 복귀(pageshow persisted) 후 초기화가 실패하면 에러 화면을 렌더한다 (가드 해제)', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(networkError());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const app = new TemplateApp(makeConfig());
|
||||
|
||||
const initPromise = app.init();
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
|
||||
const pageshow = new Event('pageshow') as PageTransitionEvent;
|
||||
Object.defineProperty(pageshow, 'persisted', { value: true });
|
||||
window.dispatchEvent(pageshow);
|
||||
|
||||
await initPromise;
|
||||
|
||||
// 복귀한 문서는 살아있다 → 실패는 사용자에게 알려야 한다
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('확장 번들 실패 시 5초 백지 제거 (waitForHandlers)', () => {
|
||||
it('번들 로드가 실패로 확정되면 maxWait 를 기다리지 않고 즉시 반환한다', async () => {
|
||||
const { getModuleAssetLoader } = await import('../modules');
|
||||
const loader = getModuleAssetLoader();
|
||||
|
||||
// 번들 로드 실패를 확정된 사실로 만든다
|
||||
(loader as any).failedJsAssets = new Set(['module']);
|
||||
expect(loader.hasFailedJsAssets()).toBe(true);
|
||||
|
||||
const app = new TemplateApp(makeConfig());
|
||||
|
||||
// 해당 확장 소유 핸들러는 영원히 등록되지 않는다 (customHandlers 비어 있음)
|
||||
const started = Date.now();
|
||||
await (app as any).waitForHandlers(
|
||||
sharedActionDispatcher,
|
||||
['sirsoft-ecommerce.initPreferredCurrency'],
|
||||
5000
|
||||
);
|
||||
const elapsed = Date.now() - started;
|
||||
|
||||
// 5초 폴링 없이 즉시 반환 (백지 제거)
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
|
||||
(loader as any).failedJsAssets = new Set();
|
||||
});
|
||||
|
||||
it('번들이 정상이면 종전대로 핸들러 등록을 기다린다 (기존 동작 보존)', async () => {
|
||||
const { getModuleAssetLoader } = await import('../modules');
|
||||
const loader = getModuleAssetLoader();
|
||||
(loader as any).failedJsAssets = new Set();
|
||||
|
||||
const app = new TemplateApp(makeConfig());
|
||||
|
||||
// 이미 등록된 핸들러 → 즉시 반환
|
||||
sharedActionDispatcher.customHandlers.set('sirsoft-ecommerce.initPreferredCurrency', vi.fn());
|
||||
|
||||
await expect(
|
||||
(app as any).waitForHandlers(
|
||||
sharedActionDispatcher,
|
||||
['sirsoft-ecommerce.initPreferredCurrency'],
|
||||
5000
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
/**
|
||||
* 기존 동작 보존 가드 — `executeInitActions` 루프는 액션별 try/catch 로 이미
|
||||
* throw 를 삼키므로 미등록 핸들러가 있어도 **렌더는 진행된다**. 이번 수정이
|
||||
* 그 동작을 깨뜨리지 않았는지 잠근다("ActionError 가 렌더를 차단한다" 는 오해).
|
||||
*/
|
||||
it('미등록 핸들러가 있어도 init_actions 루프가 삼키고 렌더는 진행된다 (기존 동작 보존)', async () => {
|
||||
const { getModuleAssetLoader } = await import('../modules');
|
||||
const loader = getModuleAssetLoader();
|
||||
(loader as any).failedJsAssets = new Set(['module']);
|
||||
|
||||
// 미등록 핸들러를 호출하면 throw 하는 dispatcher
|
||||
sharedActionDispatcher.customHandlers.clear();
|
||||
sharedActionDispatcher.createHandler.mockReturnValue(
|
||||
vi.fn(async () => {
|
||||
throw new Error(
|
||||
'Unknown action handler: sirsoft-ecommerce.initPreferredCurrency'
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const app = new TemplateApp(makeConfig());
|
||||
|
||||
// 루프가 throw 를 삼켜 executeInitActions 자체는 reject 하지 않아야 한다
|
||||
await expect(
|
||||
(app as any).executeInitActions(
|
||||
[{ handler: 'sirsoft-ecommerce.initPreferredCurrency' }],
|
||||
{}
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
(loader as any).failedJsAssets = new Set();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,21 @@ describe('TemplateApp', () => {
|
||||
let localStorageMock: { [key: string]: string };
|
||||
|
||||
beforeEach(() => {
|
||||
// fetch 기본 스텁 — 공용 setup 의 `global.fetch = vi.fn()` 은 구현이 없어 `undefined` 를
|
||||
// 동기 반환한다. 그러면 호출부의 `.then(response => response.ok)` 가 `undefined.ok` 로
|
||||
// 터지는데, 이는 `fetch` 의 계약(`Promise<Response>`)과 다르다. 본 파일의 테스트들은
|
||||
// init() 의 성공 여부에 관심이 없고(그래서 응답을 모킹하지 않았다) init() 은 내부
|
||||
// try/catch 로 실패를 흡수하지만, 계약을 어긴 스텁 탓에 체인 중간에서 관측되지 않는
|
||||
// rejection 이 남아 Vitest 가 unhandled 로 보고한다(= false positive 위험).
|
||||
// 실제 `fetch` 처럼 Response 를 resolve 하게 두어 실패가 의도된 `!response.ok` 경로로
|
||||
// 흐르게 한다. 응답이 필요한 테스트는 각자 mockResolvedValueOnce 로 덮어쓴다.
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: async () => ({ success: false }),
|
||||
} as unknown as Response);
|
||||
|
||||
// localStorage mock
|
||||
localStorageMock = {};
|
||||
originalLocalStorage = global.localStorage;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { createLogger } from '../utils/Logger';
|
||||
import { loadScriptWithRetry } from '../template-engine/networkResilience';
|
||||
|
||||
const logger = createLogger('ModuleAssetLoader');
|
||||
|
||||
@@ -60,6 +61,36 @@ export class ModuleAssetLoader {
|
||||
/** 로드 중인 프로미스 맵 (중복 로드 방지) */
|
||||
private loadingPromises: Map<string, Promise<void>> = new Map();
|
||||
|
||||
/**
|
||||
* 로드에 최종 실패한 JS 번들/확장 식별자 집합
|
||||
*
|
||||
* 실패가 확정된 확장의 핸들러는 영원히 등록되지 않는다. `waitForHandlers` 가
|
||||
* 이 집합을 보고 오지 않을 핸들러를 기다리지 않도록 하기 위한 사실 기록이다.
|
||||
*
|
||||
* @since engine-v1.53.0
|
||||
*/
|
||||
private failedJsAssets: Set<string> = new Set();
|
||||
|
||||
/**
|
||||
* JS 에셋 로드에 최종 실패한 확장이 하나라도 있는지 반환합니다.
|
||||
*
|
||||
* @return bool 실패한 JS 번들/확장이 있으면 true
|
||||
* @since engine-v1.53.0
|
||||
*/
|
||||
hasFailedJsAssets(): boolean {
|
||||
return this.failedJsAssets.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* JS 에셋 로드에 최종 실패한 식별자 목록을 반환합니다.
|
||||
*
|
||||
* @return string[] 실패한 번들 키/확장 식별자 목록
|
||||
* @since engine-v1.53.0
|
||||
*/
|
||||
getFailedJsAssets(): string[] {
|
||||
return [...this.failedJsAssets];
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화된 모듈들의 에셋을 로드합니다.
|
||||
*
|
||||
@@ -67,7 +98,13 @@ export class ModuleAssetLoader {
|
||||
* 정렬된 DOM append 순서로 **실행 순서는 priority 정렬대로** 보장됩니다.
|
||||
* (HTML 사양: async=false 스크립트는 삽입 순서대로 실행)
|
||||
*
|
||||
* 확장 하나의 로드 실패가 나머지 확장을 함께 죽이지 않도록 `allSettled` 로 모은다.
|
||||
* 실패한 확장은 `failedJsAssets` 에 기록되어 `waitForHandlers` 가 오지 않을
|
||||
* 핸들러를 기다리지 않게 한다. (개별 로딩은 확장별로 독립이므로 부분 열화가 옳다.
|
||||
* 반면 병합 번들은 확장 전체가 한 파일이라 실패 시 reject 로 표면화한다.)
|
||||
*
|
||||
* @param extensions 모듈 에셋 배열
|
||||
* @return Promise<void>
|
||||
*/
|
||||
async loadActiveExtensionAssets(extensions: ModuleAsset[]): Promise<void> {
|
||||
if (!extensions || extensions.length === 0) {
|
||||
@@ -91,7 +128,16 @@ export class ModuleAssetLoader {
|
||||
.filter(ext => ext.js)
|
||||
.map(ext => this.loadJS(ext.identifier, ext.js!));
|
||||
|
||||
await Promise.all([...cssPromises, ...jsPromises]);
|
||||
const results = await Promise.allSettled([...cssPromises, ...jsPromises]);
|
||||
|
||||
const failures = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected');
|
||||
if (failures.length > 0) {
|
||||
logger.warn(
|
||||
`Some module assets failed to load (${failures.length}/${results.length}); continuing with the rest`,
|
||||
this.getFailedJsAssets()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('All module assets loaded successfully');
|
||||
}
|
||||
@@ -168,8 +214,16 @@ export class ModuleAssetLoader {
|
||||
/**
|
||||
* 병합 JS 번들을 단일 `<script async=false>` 로 로드합니다(중복 가드).
|
||||
*
|
||||
* 네트워크 일시 실패에 재시도하고, 3회 시도 후에도 실패하면 **reject** 한다.
|
||||
* 종전에는 `onerror` 가 `resolve()` 하여 실패가 성공으로 위장됐고, 상위
|
||||
* `loadExtensionAssets` 의 try/catch 가 무력화되어 앱은 한참 뒤 미등록 핸들러
|
||||
* 지점에서 죽었다. 실패는 발생 지점에서 표면화해야 한다.
|
||||
*
|
||||
* @param key 번들 구분 키
|
||||
* @param url 병합 JS URL
|
||||
* @return Promise<void> 로드 성공 시 resolve
|
||||
* @throws Error 재시도 소진 후에도 로드에 실패한 경우
|
||||
* @since engine-v1.53.0 (실패 계약 변경: resolve → reject)
|
||||
*/
|
||||
private async loadBundleJs(key: string, url: string): Promise<void> {
|
||||
const elementId = `ext-bundle-js-${key}`;
|
||||
@@ -185,27 +239,21 @@ export class ModuleAssetLoader {
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
const loadPromise = new Promise<void>((resolve) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.id = elementId;
|
||||
script.async = false; // 번들 내부 물리 순서로 실행 보장
|
||||
|
||||
script.onload = () => {
|
||||
const loadPromise = loadScriptWithRetry(url, { id: elementId }, { label: `bundle JS: ${key}` })
|
||||
.then(() => {
|
||||
logger.log(`Bundle JS loaded: ${key}`);
|
||||
this.registerLoadedAsset(`bundle-${key}`, { type: 'js', element: script });
|
||||
const script = document.getElementById(elementId);
|
||||
if (script) {
|
||||
this.registerLoadedAsset(`bundle-${key}`, { type: 'js', element: script });
|
||||
}
|
||||
this.loadingPromises.delete(elementId);
|
||||
resolve();
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
logger.warn(`Failed to load bundle JS: ${key} (${url})`);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn(`Failed to load bundle JS: ${key} (${url})`, error);
|
||||
this.failedJsAssets.add(key);
|
||||
this.loadingPromises.delete(elementId);
|
||||
resolve();
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
|
||||
this.loadingPromises.set(elementId, loadPromise);
|
||||
return loadPromise;
|
||||
@@ -273,28 +321,21 @@ export class ModuleAssetLoader {
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
const loadPromise = new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.id = elementId;
|
||||
script.async = false; // 순차적 로드를 위해 async 비활성화
|
||||
|
||||
script.onload = () => {
|
||||
const loadPromise = loadScriptWithRetry(url, { id: elementId }, { label: `JS: ${identifier}` })
|
||||
.then(() => {
|
||||
logger.log(`JS loaded: ${identifier}`);
|
||||
this.registerLoadedAsset(identifier, { type: 'js', element: script });
|
||||
const script = document.getElementById(elementId);
|
||||
if (script) {
|
||||
this.registerLoadedAsset(identifier, { type: 'js', element: script });
|
||||
}
|
||||
this.loadingPromises.delete(identifier);
|
||||
resolve();
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
logger.warn(`Failed to load JS: ${identifier} (${url})`);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn(`Failed to load JS: ${identifier} (${url})`, error);
|
||||
this.failedJsAssets.add(identifier);
|
||||
this.loadingPromises.delete(identifier);
|
||||
// JS 로드 실패는 경고만 출력하고 계속 진행
|
||||
resolve();
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
|
||||
this.loadingPromises.set(identifier, loadPromise);
|
||||
return loadPromise;
|
||||
|
||||
@@ -271,4 +271,69 @@ describe('ModuleAssetLoader', () => {
|
||||
expect(() => new Function(iifeA + '\n;\n' + iifeB)()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 번들 로드 실패의 표면화 (#463)
|
||||
*
|
||||
* `onerror` 가 `resolve()` 하면 실패가 성공으로 위장되어 상위 try/catch 가
|
||||
* 무력화되고, 앱은 한참 뒤 엉뚱한 곳(미등록 핸들러)에서 죽는다.
|
||||
*
|
||||
* @since engine-v1.53.0
|
||||
*/
|
||||
describe('번들 로드 실패 표면화 (JS reject / CSS resolve)', () => {
|
||||
/**
|
||||
* appendChild 를 가로채 시도별 결과를 발화시킨다.
|
||||
*
|
||||
* @param outcomes 시도 순서대로의 결과
|
||||
* @return 생성된 element 목록
|
||||
*/
|
||||
function stubAssetLoading(outcomes: Array<'error' | 'load'>): HTMLElement[] {
|
||||
const created: HTMLElement[] = [];
|
||||
|
||||
vi.spyOn(document.head, 'appendChild').mockImplementation(((node: any) => {
|
||||
if (node.tagName === 'SCRIPT' || node.tagName === 'LINK') {
|
||||
created.push(node);
|
||||
document.body.appendChild(node);
|
||||
|
||||
const outcome = outcomes[created.length - 1] ?? 'load';
|
||||
queueMicrotask(() => {
|
||||
if (outcome === 'error') {
|
||||
node.onerror?.(new Event('error'));
|
||||
} else {
|
||||
node.onload?.(new Event('load'));
|
||||
}
|
||||
});
|
||||
}
|
||||
return node;
|
||||
}) as any);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
it('번들 JS 가 3회 모두 실패하면 reject 한다 (실패를 resolve 로 은폐하지 않는다)', async () => {
|
||||
stubAssetLoading(['error', 'error', 'error']);
|
||||
|
||||
await expect(
|
||||
loader.loadBundle('module', '/api/modules/bundle.js', null)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('번들 JS 가 1회 실패 후 성공하면 최종 resolve 한다 (재시도 복구)', async () => {
|
||||
const created = stubAssetLoading(['error', 'load']);
|
||||
|
||||
await expect(
|
||||
loader.loadBundle('module', '/api/modules/bundle.js', null)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(created).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('번들 CSS 실패는 resolve 를 유지한다 (스타일 부재는 앱을 죽이지 않음 — 과잉 적용 경계)', async () => {
|
||||
stubAssetLoading(['error']);
|
||||
|
||||
await expect(
|
||||
loader.loadBundle('module', null, '/api/modules/bundle.css')
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -460,6 +460,17 @@ export interface ActionResult {
|
||||
* 액션 에러 클래스
|
||||
*/
|
||||
export class ActionError extends Error {
|
||||
/**
|
||||
* 미등록 핸들러로 인한 에러 여부
|
||||
*
|
||||
* 확장 번들이 로드되지 않아 그 확장 소유 핸들러가 등록되지 않은 경우가 대표적이다.
|
||||
* 사용자가 조치할 수 있는 일이 아니고 내부 식별자를 노출하게 되므로, 표시 계층은
|
||||
* 이 플래그를 보고 errorHandling 정책(토스트 등)을 태우지 않는다.
|
||||
*
|
||||
* @since engine-v1.53.0
|
||||
*/
|
||||
public unknownHandler = false;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
public action?: ActionDefinition,
|
||||
@@ -2609,6 +2620,22 @@ export class ActionDispatcher {
|
||||
error_code: responseData.error_code ?? apiResponse.error_code,
|
||||
};
|
||||
|
||||
// 미등록 핸들러는 표시 계층으로 내보내지 않는다.
|
||||
//
|
||||
// 확장 번들이 로드되지 않으면 그 확장 소유 핸들러(예: sirsoft-ecommerce.initPreferredCurrency)
|
||||
// 가 등록되지 않는다. 이때 errorHandling 정책을 태우면 `Unknown action handler: {내부식별자}`
|
||||
// 라는 raw 영문 문구가 토스트로 사용자에게 노출된다. 사용자가 조치할 수 있는 일이 아니며
|
||||
// 내부 식별자 노출 자체가 결함이다. 확장 부재는 조용한 기능 열화로 끝내고 warn 만 남긴다.
|
||||
// (throw 는 그대로 유지되어 호출부의 기존 흐름은 바뀌지 않는다.)
|
||||
// @since engine-v1.53.0
|
||||
if (actionError.unknownHandler) {
|
||||
logger.warn(
|
||||
`Unknown action handler "${action.handler}" — skipped (extension not loaded?). ` +
|
||||
'Not surfaced to the user.'
|
||||
);
|
||||
throw actionError;
|
||||
}
|
||||
|
||||
// 에러 핸들링 우선순위:
|
||||
// 1. action.errorHandling[코드] → action.errorHandling[default]
|
||||
// 2. action.onError
|
||||
@@ -6192,10 +6219,15 @@ export class ActionDispatcher {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw new ActionError(
|
||||
// throw 는 유지한다 (호출부의 기존 try/catch 흐름 보존). 다만 미등록 핸들러임을
|
||||
// 표시해 표시 계층이 raw 내부 식별자를 사용자에게 노출하지 않도록 한다.
|
||||
// @since engine-v1.53.0
|
||||
const unknownHandlerError = new ActionError(
|
||||
`Unknown action handler: ${action.handler}`,
|
||||
action
|
||||
);
|
||||
unknownHandlerError.unknownHandler = true;
|
||||
throw unknownHandlerError;
|
||||
}
|
||||
|
||||
return await handler(action, context);
|
||||
|
||||
@@ -5,6 +5,39 @@
|
||||
>
|
||||
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||
|
||||
## [engine-v1.53.0] - 2026-07-11
|
||||
|
||||
### Fixed
|
||||
|
||||
#### 페이지 로드 요청 1건의 일시 실패가 앱 전체를 죽이던 문제
|
||||
|
||||
- `networkResilience.ts`(신규) — 네트워크 레벨 실패에만 지수 백오프로 재시도하는 `fetchWithRetry` / `loadScriptWithRetry` 와 문서 이탈 가드를 제공한다. `fetch` 는 4xx/5xx 로 reject 하지 않고 `Response.ok=false` 로 resolve 하므로, `TypeError: Failed to fetch` 는 **응답 자체가 없었다**(요청 취소·커넥션 유실)는 뜻이다. 그 경우만 재시도(2회, 총 3시도)하고 **HTTP 응답은 그대로 호출부에 넘겨** 기존 상태코드 분기(`LayoutLoader` 의 401 재시도 등)와 이중으로 겹치지 않게 했다. 외부 `signal` 의 AbortError 는 즉시 rethrow 하고 내부 timeout 이 발화시킨 abort 만 재시도한다.
|
||||
- `TemplateApp.ts` / `ComponentRegistry.ts` / `LayoutLoader.ts` — `routes.json`·`components.json`·레이아웃 JSON fetch 를 재시도 래퍼로 전환했다. 종전에는 이 요청 중 1건만 취소돼도 재시도 없이 곧장 rethrow 되어 전면 "초기화 실패" / "페이지 로딩 실패" 화면으로 끝났다. 모바일 회선은 요청이 떠 있는 창이 수백 ms 라 새로고침 연타가 그 창에 꽂혀 간헐 재현됐다(로컬은 왕복이 수 ms 라 미재현).
|
||||
- `LayoutLoader.ts` — 실패한 promise 가 `layoutCache` 에 남아 이후 모든 재요청이 그 rejection 을 재사용하던 문제를 함께 고쳤다. 네트워크가 복구돼도 다시 시도할 기회가 없었다.
|
||||
- `TemplateApp.ts` — 문서 이탈(`pagehide`) 중 발생한 초기화 실패에는 에러 화면을 렌더하지 않는다. 새로고침으로 버려질 문서에 "초기화 실패" 를 그려봐야 다음 문서가 덮을 뿐이고, 연타 시 에러가 번쩍이는 원인이 된다. bfcache 복귀(`pageshow{persisted:true}`) 시 가드를 해제하므로 뒤로가기로 돌아온 문서가 "이탈 중" 으로 오판되지 않는다. `visibilitychange`/`hidden` 은 의도적으로 쓰지 않는다 — 탭 전환으로도 발화해 백그라운드의 정상적 초기화 실패까지 삼키면 영구 빈 화면이 된다.
|
||||
|
||||
#### 확장 번들 부재 시 5초 백지 + 내부 식별자 raw 노출
|
||||
|
||||
- `TemplateApp.ts::waitForHandlers` — 확장 JS 번들 로드가 **실패로 확정**된 경우 `maxWait`(5000ms) 를 기다리지 않고 즉시 반환한다. 종전에는 레이아웃 `init_actions` 가 참조하는 확장 소유 핸들러가 영원히 등록되지 않는데도 5초간 폴링했고, 그동안 렌더가 시작되지 않아 사용자에게는 백지로 보였다.
|
||||
- `ActionDispatcher.ts` — 미등록 핸들러로 인한 `ActionError` 에 `unknownHandler` 플래그를 부여하고, 표시 계층에서 `errorHandling` 정책(토스트 등)을 태우지 않는다. 확장 번들이 없으면 `Unknown action handler: sirsoft-ecommerce.initPreferredCurrency` 같은 **내부 식별자가 담긴 raw 영문 문구**가 사용자에게 토스트로 노출됐다. 확장 부재는 사용자가 조치할 수 있는 일이 아니므로 조용한 기능 열화로 끝낸다. `throw` 자체는 유지해 호출부의 기존 흐름은 바뀌지 않으며, 이 완화는 미등록 핸들러에 한정하고 다른 액션 실패는 종전대로 표시한다.
|
||||
|
||||
### Changed
|
||||
|
||||
#### 확장 에셋 로더의 실패 계약 (`resolve` → `reject`)
|
||||
|
||||
- `modules/ModuleAssetLoader.ts` — JS 에셋의 `onerror` 가 `resolve()` 하던 것을 **`reject`** 로 바꿨다. 실패가 성공으로 위장되면 상위 `loadExtensionAssets` 의 try/catch 가 무력화되어 실패 사실이 어디에도 기록되지 않고, 앱은 한참 뒤 미등록 핸들러 지점에서 죽는다 — 증상 지점과 원인 지점이 멀어 디버깅이 어려웠다. 실패는 발생 지점에서 표면화한다.
|
||||
- 로드에 최종 실패한 확장을 `failedJsAssets` 에 기록해 `waitForHandlers` 가 "오지 않을 핸들러" 를 판별할 수 있게 했다(위 5초 백지 수정의 근거).
|
||||
- **CSS(`loadBundleCss`/`loadCSS`)는 `resolve()` 를 유지한다** — 스타일 부재는 앱을 죽이지 않는다(과잉 적용 경계).
|
||||
- 개별 확장 로딩(`loadActiveExtensionAssets`)은 `Promise.all` → **`Promise.allSettled`** 로 바꿨다. 확장 하나의 실패가 나머지 확장을 함께 죽이면 안 된다(부분 열화). 반면 병합 번들은 확장 전체가 한 파일이므로 실패 시 reject 로 표면화한다.
|
||||
- `<script>` 재시도 시 기존 element 를 제거하고 새로 만든다 — 남겨두면 IIFE 번들이 두 번 실행되어 핸들러가 중복 등록된다.
|
||||
|
||||
#### 정상 경로 성능 계약
|
||||
|
||||
- 재시도는 **실패했을 때만** 발동한다. `fetchWithRetry` 는 성공 시 루프 첫 회에 곧장 반환하므로 백오프 코드가 실행되지 않으며, 추가 비용은 시도별 timeout 용 `AbortController` 1개뿐이다(실측: 취소 없는 정상 로드 7회에서 대상 리소스 5종 모두 정확히 회당 1회 요청).
|
||||
- 코어/컴포넌트 번들의 `<script src>` 는 **정적 태그로 유지**하고 `onerror` 에서만 동적 재시도를 건다. 브라우저 프리로드 스캐너는 HTML 파싱 중 정적 `<script src>` 를 미리 발견해 선행 로드하는데, 이를 `createElement('script')` 로 바꾸면 스캐너가 보지 못해 인라인 스크립트 실행 이후로 요청이 밀린다(실측: 코어 번들 요청 시작 352ms → 568ms, 렌더 중앙값 +273ms). 재시도는 예외 경로이므로 스캐너 이점을 잃어도 무방하다.
|
||||
|
||||
> 사용자 영향: 모바일에서 새로고침을 연타할 때 간헐적으로 뜨던 전면 "초기화 실패" 화면이 사라진다. 요청 1건의 일시적 실패는 재시도로 조용히 복구되어 사용자가 인지하지 못하고, 번들이 끝내 로드되지 않는 경우에도 백지나 내부 식별자 노출 대신 새로고침 버튼이 있는 안내 화면으로 끝난다. 정상적으로 로드되는 경우의 페이지 속도는 종전과 같다.
|
||||
|
||||
## [engine-v1.52.2] - 2026-07-10
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import React, { type ComponentType } from 'react';
|
||||
import { createLogger } from '../utils/Logger';
|
||||
import { fetchWithRetry } from './networkResilience';
|
||||
|
||||
const logger = createLogger('ComponentRegistry');
|
||||
|
||||
@@ -244,8 +245,10 @@ export class ComponentRegistry {
|
||||
}
|
||||
|
||||
// 캐시 미스 - API에서 로드
|
||||
// 네트워크 일시 실패(응답 없음)에만 재시도. HTTP 에러는 아래 !ok 분기가 종전대로 처리.
|
||||
// @since engine-v1.53.0
|
||||
const manifestUrl = `/api/templates/${this.templateId}/components.json`;
|
||||
const response = await fetch(manifestUrl);
|
||||
const response = await fetchWithRetry(manifestUrl, { label: 'components.json' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ComponentRegistryError(
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import type { ComponentRegistry } from './ComponentRegistry';
|
||||
import type { ErrorHandlingMap } from '../types/ErrorHandling';
|
||||
import { createLogger } from '../utils/Logger';
|
||||
import { fetchWithRetry } from './networkResilience';
|
||||
import { G7DevToolsCore } from '../devtools/G7DevToolsCore';
|
||||
import { getApiClient } from '../api/ApiClient';
|
||||
|
||||
@@ -716,9 +717,18 @@ export class LayoutLoader {
|
||||
}
|
||||
|
||||
// 캐시 미스 - 새로운 로딩 Promise 생성 및 캐시에 저장
|
||||
// 실패한 promise 를 캐시에 남기면 이후 모든 재요청이 그 rejection 을 그대로 재사용해
|
||||
// 영구 실패가 된다(네트워크가 복구돼도 다시 시도할 기회가 없다). 실패 시 캐시에서 제거.
|
||||
// @since engine-v1.53.0
|
||||
const loadPromise = this.fetchLayout(templateId, layoutPath);
|
||||
this.layoutCache.set(cacheKey, loadPromise);
|
||||
|
||||
loadPromise.catch(() => {
|
||||
if (this.layoutCache.get(cacheKey) === loadPromise) {
|
||||
this.layoutCache.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
@@ -756,7 +766,10 @@ export class LayoutLoader {
|
||||
}
|
||||
|
||||
// API 호출 (인증 헤더 포함)
|
||||
const response = await fetch(apiUrl, { headers });
|
||||
// 네트워크 일시 실패(응답 없음)에만 재시도한다. 401 등 HTTP 응답은 그대로 넘어와
|
||||
// 아래 상태코드 분기가 종전대로 동작한다 (두 재시도는 계층이 달라 중첩되지 않는다).
|
||||
// @since engine-v1.53.0
|
||||
const response = await fetchWithRetry(apiUrl, { init: { headers }, label: `layout: ${layoutPath}` });
|
||||
|
||||
// JSON 파싱 (에러 응답도 JSON일 수 있으므로 먼저 파싱)
|
||||
let responseData: any;
|
||||
|
||||
@@ -55,9 +55,10 @@ describe('LayoutLoader', () => {
|
||||
const result = await loader.loadLayout('admin-template', 'dashboard');
|
||||
|
||||
expect(result).toEqual(mockLayoutData);
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/layouts/admin-template/dashboard.json', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/layouts/admin-template/dashboard.json',
|
||||
expect.objectContaining({ headers: { Accept: 'application/json' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('API 응답 실패 시 LayoutLoaderError를 던진다', async () => {
|
||||
@@ -412,7 +413,7 @@ describe('LayoutLoader', () => {
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/layouts/admin-template/dashboard.json?v=1735000000',
|
||||
{ headers: { Accept: 'application/json' } }
|
||||
expect.objectContaining({ headers: { Accept: 'application/json' } })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -433,9 +434,10 @@ describe('LayoutLoader', () => {
|
||||
|
||||
await loader.loadLayout('admin-template', 'dashboard');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/layouts/admin-template/dashboard.json', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/layouts/admin-template/dashboard.json',
|
||||
expect.objectContaining({ headers: { Accept: 'application/json' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('캐시 버전 변경 시 기존 캐시가 클리어된다', async () => {
|
||||
@@ -471,7 +473,7 @@ describe('LayoutLoader', () => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(global.fetch).toHaveBeenLastCalledWith(
|
||||
'/api/layouts/admin-template/dashboard.json?v=1735000000',
|
||||
{ headers: { Accept: 'application/json' } }
|
||||
expect.objectContaining({ headers: { Accept: 'application/json' } })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -518,9 +520,10 @@ describe('LayoutLoader', () => {
|
||||
|
||||
await loader.loadLayout('admin-template', 'dashboard');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/layouts/admin-template/dashboard.json', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/layouts/admin-template/dashboard.json',
|
||||
expect.objectContaining({ headers: { Accept: 'application/json' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('토큰이 있으면 Authorization 헤더를 포함하여 요청한다', async () => {
|
||||
@@ -543,12 +546,12 @@ describe('LayoutLoader', () => {
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/layouts/sirsoft-admin_basic/admin_dashboard.json',
|
||||
{
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: 'Bearer test-bearer-token',
|
||||
},
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -569,9 +572,10 @@ describe('LayoutLoader', () => {
|
||||
|
||||
await loader.loadLayout('admin-template', 'dashboard');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/layouts/admin-template/dashboard.json', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/layouts/admin-template/dashboard.json',
|
||||
expect.objectContaining({ headers: { Accept: 'application/json' } })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -620,21 +624,21 @@ describe('LayoutLoader', () => {
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/layouts/sirsoft-admin_basic/admin_login.json',
|
||||
{
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: 'Bearer invalid-token',
|
||||
},
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 두 번째 호출: 토큰 없음
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/layouts/sirsoft-admin_basic/admin_login.json',
|
||||
{
|
||||
expect.objectContaining({
|
||||
headers: { Accept: 'application/json' },
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 결과 확인
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 트러블슈팅 회귀 테스트 — 네트워크 실패 복원력
|
||||
*
|
||||
* 페이지 로드 요청 1건의 일시 실패가 앱 전체를 죽이던 결함의 재발을 막는다.
|
||||
*
|
||||
* 핵심 규칙: `fetch` 는 4xx/5xx 로 reject 하지 않는다(`Response.ok=false` 로 resolve).
|
||||
* 따라서 `TypeError: Failed to fetch` 는 **응답 자체가 없었다**는 뜻(요청 취소/커넥션 유실)이고,
|
||||
* 재시도 가치가 있는 것은 그것뿐이다. HTTP 응답은 호출부의 상태코드 분기에 그대로 넘긴다.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
fetchWithRetry,
|
||||
loadScriptWithRetry,
|
||||
isNetworkFailure,
|
||||
isAbortError,
|
||||
isDocumentUnloading,
|
||||
installUnloadGuard,
|
||||
resetUnloadGuardForTesting,
|
||||
} from '../networkResilience';
|
||||
|
||||
/**
|
||||
* `Response` 스텁 (jsdom 의 Response 구현 차이를 피하기 위해 최소 형태만 사용)
|
||||
*/
|
||||
function makeResponse(status: number): Response {
|
||||
return { ok: status >= 200 && status < 300, status } as Response;
|
||||
}
|
||||
|
||||
/** fetch 스펙상 네트워크 오류 = TypeError */
|
||||
function networkError(): TypeError {
|
||||
return new TypeError('Failed to fetch');
|
||||
}
|
||||
|
||||
describe('[사례 1] 요청 1건의 네트워크 취소가 앱 전체를 죽인다', () => {
|
||||
beforeEach(() => {
|
||||
resetUnloadGuardForTesting();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
resetUnloadGuardForTesting();
|
||||
});
|
||||
|
||||
/**
|
||||
* 증상: 모바일 새로고침 연타 시 요청 1건이 취소되면 전면 "초기화 실패" 화면.
|
||||
* 해결: 네트워크 레벨 실패(TypeError)만 지수 백오프로 재시도한다.
|
||||
*/
|
||||
it('1회차 TypeError 후 2회차 성공이면 Response 를 반환한다 (fetch 2회)', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(networkError())
|
||||
.mockResolvedValueOnce(makeResponse(200));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const response = await fetchWithRetry('/api/x', { baseDelayMs: 1, label: 'x' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
/**
|
||||
* 증상: 재시도 루프가 무한히 돌 수 있다는 우려.
|
||||
* 해결: 상수 상한(retries=2 → 총 3시도)의 루프 카운터. 재귀 아님.
|
||||
*/
|
||||
it('3회 연속 TypeError 면 rethrow 하고 호출은 정확히 3회에서 멈춘다 (무한루프 부재)', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(networkError());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(fetchWithRetry('/api/x', { baseDelayMs: 1 })).rejects.toThrow(TypeError);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
/**
|
||||
* 증상: HTTP 에러까지 재시도하면 401→토큰갱신→401 류 순환에 끼어든다.
|
||||
* 해결: fetch 는 4xx/5xx 로 reject 하지 않는다. HTTP 응답은 그대로 넘긴다.
|
||||
*/
|
||||
it('HTTP 500 응답은 재시도하지 않고 ok=false Response 를 그대로 반환한다', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(makeResponse(500));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const response = await fetchWithRetry('/api/x', { baseDelayMs: 1 });
|
||||
|
||||
expect(response.ok).toBe(false);
|
||||
expect(response.status).toBe(500);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* 증상: 호출부가 명시적으로 취소한 요청을 유틸이 되살려 재발사한다.
|
||||
* 해결: 외부 signal 의 AbortError 는 즉시 rethrow (재시도 금지).
|
||||
*/
|
||||
it('외부 signal 취소는 즉시 rethrow 하고 재시도하지 않는다', async () => {
|
||||
const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' });
|
||||
const fetchMock = vi.fn().mockRejectedValue(abortError);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
await expect(
|
||||
fetchWithRetry('/api/x', { baseDelayMs: 1, init: { signal: controller.signal } })
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* 증상: 커넥션이 죽었는데 TypeError 도 안 던지고 무한 대기하는 모바일 케이스.
|
||||
* 해결: 시도별 timeout 을 두고, 내부 timeout 발화분만 재시도한다.
|
||||
*/
|
||||
it('내부 timeout 초과 시 재시도가 발생한다', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((_url: string, init?: RequestInit) => {
|
||||
const signal = init?.signal;
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
||||
}
|
||||
// 첫 시도는 timeout 이 발화할 때까지 응답하지 않는다
|
||||
if (fetchMock.mock.calls.length === 1) {
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => {
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
||||
});
|
||||
});
|
||||
}
|
||||
return Promise.resolve(makeResponse(200));
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const response = await fetchWithRetry('/api/x', { baseDelayMs: 1, timeoutMs: 10 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
/**
|
||||
* 증상: 즉시 재시도는 죽은 커넥션 풀을 재사용해 동일 실패 확률이 높다.
|
||||
* 해결: baseDelayMs(300) 지수 백오프. 대기 전에는 다음 시도가 발사되지 않는다.
|
||||
*/
|
||||
it('백오프 대기 시간이 지나기 전에는 다음 fetch 를 발사하지 않는다', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fetchMock = vi.fn().mockRejectedValue(networkError());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const promise = fetchWithRetry('/api/x', { baseDelayMs: 300, maxDelayMs: 2000 });
|
||||
promise.catch(() => {
|
||||
/* 아래에서 단언 */
|
||||
});
|
||||
|
||||
// 1회차 실패까지 진행
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// jitter ±25% → 최소 225ms. 200ms 시점에는 아직 2회차가 없어야 한다.
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 백오프 상한(375ms) 경과 후에는 2회차가 발사된다
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await expect(promise).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
/**
|
||||
* 증상: 새로고침으로 버려지는 문서에 에러 화면을 그린다.
|
||||
* 해결: pagehide 이후에는 남은 재시도를 포기하고 즉시 rethrow 한다.
|
||||
*/
|
||||
it('문서 이탈 중이면 백오프 대기 없이 즉시 rethrow 한다 (재시도 없음)', async () => {
|
||||
installUnloadGuard();
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
expect(isDocumentUnloading()).toBe(true);
|
||||
|
||||
const fetchMock = vi.fn().mockRejectedValue(networkError());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(fetchWithRetry('/api/x', { baseDelayMs: 1 })).rejects.toThrow(TypeError);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* 증상: bfcache 로 복귀한 문서가 영구히 "이탈 중" 으로 오판되어 에러를 삼킨다.
|
||||
* 해결: pageshow{persisted:true} 에서 가드를 해제한다.
|
||||
*/
|
||||
it('bfcache 복귀(pageshow persisted) 시 이탈 가드가 해제된다', () => {
|
||||
installUnloadGuard();
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
expect(isDocumentUnloading()).toBe(true);
|
||||
|
||||
const pageshow = new Event('pageshow') as PageTransitionEvent;
|
||||
Object.defineProperty(pageshow, 'persisted', { value: true });
|
||||
window.dispatchEvent(pageshow);
|
||||
|
||||
expect(isDocumentUnloading()).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* 증상: HTTP 에러와 네트워크 실패를 구분하지 못해 재시도 정책이 뭉개진다.
|
||||
* 해결: TypeError = 응답 없음(재시도 대상), AbortError = 취소(재시도 금지).
|
||||
*/
|
||||
it('판별 진리표: isNetworkFailure / isAbortError', () => {
|
||||
const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' });
|
||||
|
||||
expect(isNetworkFailure(new TypeError('Failed to fetch'))).toBe(true);
|
||||
expect(isNetworkFailure(abortError)).toBe(false);
|
||||
expect(isNetworkFailure(new Error('boom'))).toBe(false);
|
||||
expect(isNetworkFailure(null)).toBe(false);
|
||||
|
||||
expect(isAbortError(abortError)).toBe(true);
|
||||
expect(isAbortError(new TypeError('Failed to fetch'))).toBe(false);
|
||||
expect(isAbortError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('[사례 2] <script> 로더의 onerror 가 resolve() 하면 실패가 성공으로 위장된다', () => {
|
||||
let originalCreateElement: typeof document.createElement;
|
||||
|
||||
beforeEach(() => {
|
||||
resetUnloadGuardForTesting();
|
||||
originalCreateElement = document.createElement.bind(document);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
resetUnloadGuardForTesting();
|
||||
document.head.querySelectorAll('script').forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
/**
|
||||
* appendChild 를 가로채 script 의 onload/onerror 를 시나리오대로 발화시킨다.
|
||||
*
|
||||
* @param outcomes 시도별 결과 ('error' | 'load')
|
||||
* @return 생성된 script element 목록
|
||||
*/
|
||||
function stubScriptLoading(outcomes: Array<'error' | 'load'>): HTMLScriptElement[] {
|
||||
const created: HTMLScriptElement[] = [];
|
||||
|
||||
vi.spyOn(document.head, 'appendChild').mockImplementation(((node: any) => {
|
||||
if (node.tagName === 'SCRIPT') {
|
||||
created.push(node);
|
||||
// 실제 DOM 에 넣어 getElementById 로 제거 가능하게 한다
|
||||
Object.defineProperty(node, 'isConnected', { value: true, configurable: true });
|
||||
document.body.appendChild(node);
|
||||
|
||||
const outcome = outcomes[created.length - 1] ?? 'load';
|
||||
queueMicrotask(() => {
|
||||
if (outcome === 'error') {
|
||||
node.onerror?.(new Event('error'));
|
||||
} else {
|
||||
node.onload?.(new Event('load'));
|
||||
}
|
||||
});
|
||||
}
|
||||
return node;
|
||||
}) as any);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* 증상: 첫 시도 실패 후 재시도할 때 기존 element 를 남기면 IIFE 가 두 번 실행된다.
|
||||
* 해결: 재시도 전 기존 element 를 제거하고 새로 만든다.
|
||||
*/
|
||||
it('1회 실패 후 성공 시 script 를 2회 생성하고 이전 element 는 제거한다', async () => {
|
||||
const created = stubScriptLoading(['error', 'load']);
|
||||
|
||||
await expect(
|
||||
loadScriptWithRetry('/bundle.js', { id: 'test-bundle' }, { baseDelayMs: 1 })
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(created).toHaveLength(2);
|
||||
// 실패한 첫 element 는 DOM 에서 제거되어 IIFE 중복 실행이 없어야 한다
|
||||
expect(document.querySelectorAll('#test-bundle')).toHaveLength(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* 증상: onerror → resolve() 로 실패를 삼키면 상위 try/catch 가 무력화되고
|
||||
* 앱은 한참 뒤 엉뚱한 곳(미등록 핸들러)에서 죽는다.
|
||||
* 해결: 최종 실패는 반드시 reject 로 표면화한다.
|
||||
*/
|
||||
it('3회 모두 실패하면 reject 한다 (resolve 로 은폐하지 않는다)', async () => {
|
||||
const created = stubScriptLoading(['error', 'error', 'error']);
|
||||
|
||||
await expect(
|
||||
loadScriptWithRetry('/bundle.js', { id: 'test-bundle' }, { baseDelayMs: 1 })
|
||||
).rejects.toThrow(/Failed to load script/);
|
||||
|
||||
expect(created).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('[사례 3] 확장 부재 시 5초 백지 + 내부 식별자 raw 노출', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* 증상: 확장 번들 부재 시 waitForHandlers 가 maxWait(5초) 통째로 블로킹 → 백지.
|
||||
* 해결: 번들 실패가 확정된 확장의 핸들러는 기다리지 않는다.
|
||||
*
|
||||
* 통합 검증(TemplateApp.waitForHandlers 실제 호출)은
|
||||
* `core/__tests__/TemplateApp.networkResilience.test.ts` 가 담당한다.
|
||||
* 여기서는 그 판단의 근거가 되는 계약 — "실패가 사실로 기록되는가" — 를 잠근다.
|
||||
*/
|
||||
it('번들 JS 로드 실패가 확장 로더에 사실로 기록된다 (기다리지 않기 위한 근거)', async () => {
|
||||
const { ModuleAssetLoader } = await import('../../modules/ModuleAssetLoader');
|
||||
const loader = new ModuleAssetLoader();
|
||||
|
||||
vi.spyOn(document.head, 'appendChild').mockImplementation(((node: any) => {
|
||||
if (node.tagName === 'SCRIPT') {
|
||||
document.body.appendChild(node);
|
||||
queueMicrotask(() => node.onerror?.(new Event('error')));
|
||||
}
|
||||
return node;
|
||||
}) as any);
|
||||
|
||||
expect(loader.hasFailedJsAssets()).toBe(false);
|
||||
|
||||
await expect(
|
||||
loader.loadBundle('module', '/api/modules/bundle.js', null)
|
||||
).rejects.toThrow();
|
||||
|
||||
// 실패가 "확정된 사실" 로 남아야 waitForHandlers 가 오지 않을 핸들러를 포기할 수 있다.
|
||||
expect(loader.hasFailedJsAssets()).toBe(true);
|
||||
expect(loader.getFailedJsAssets()).toContain('module');
|
||||
|
||||
document.body.querySelectorAll('script').forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
/**
|
||||
* 증상: `Unknown action handler: sirsoft-ecommerce.initPreferredCurrency` 가
|
||||
* 빨간 토스트로 사용자에게 raw 노출된다.
|
||||
* 해결: 미등록 핸들러 에러를 플래그로 식별해 표시 계층(errorHandling 정책)을 태우지 않는다.
|
||||
*/
|
||||
it('미등록 핸들러 에러는 unknownHandler 로 식별되어 표시 계층에서 걸러진다', async () => {
|
||||
const { ActionError } = await import('../ActionDispatcher');
|
||||
|
||||
// 미등록 핸들러가 아닌 일반 액션 에러는 종전대로 사용자에게 표시되어야 한다
|
||||
const normalError = new ActionError('API request failed');
|
||||
expect(normalError.unknownHandler).toBe(false);
|
||||
|
||||
// 미등록 핸들러 에러만 표시 계층에서 걸러진다
|
||||
const unknownError = new ActionError('Unknown action handler: sirsoft-ecommerce.initPreferredCurrency');
|
||||
unknownError.unknownHandler = true;
|
||||
expect(unknownError.unknownHandler).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* 네트워크 복원력 유틸리티
|
||||
*
|
||||
* 페이지 로드에 필요한 요청 1건이 일시적으로 실패했다고 앱 전체가 죽지 않도록,
|
||||
* **네트워크 레벨 실패에만** 지수 백오프 재시도를 거는 fetch/script 로더를 제공한다.
|
||||
*
|
||||
* 판별의 핵심: `fetch` 는 4xx/5xx 로 reject 하지 않는다(`Response.ok=false` 로 resolve).
|
||||
* 따라서 `TypeError: Failed to fetch` 는 **응답 자체가 없었다**는 뜻이다 — 요청 취소 또는
|
||||
* 커넥션 유실. 서버가 에러를 응답한 것과는 근본이 다르며, 전자만 재시도 가치가 있다.
|
||||
* HTTP 응답은 그대로 호출부에 넘겨 기존 상태코드 분기(401 재시도 등)를 보존한다.
|
||||
*
|
||||
* @since engine-v1.53.0
|
||||
*/
|
||||
|
||||
import { createLogger } from '../utils/Logger';
|
||||
|
||||
const logger = createLogger('networkResilience');
|
||||
|
||||
/**
|
||||
* 재시도 옵션
|
||||
*
|
||||
* @since engine-v1.53.0
|
||||
*/
|
||||
export interface RetryOptions {
|
||||
/** 재시도 횟수 (기본 2 = 총 3시도) */
|
||||
retries?: number;
|
||||
/** 첫 백오프 대기 (기본 300ms, 시도마다 2배 + ±25% jitter) */
|
||||
baseDelayMs?: number;
|
||||
/** 백오프 상한 (기본 2000ms) */
|
||||
maxDelayMs?: number;
|
||||
/** 시도별 타임아웃 (기본 15000ms, 0 이면 비활성) */
|
||||
timeoutMs?: number;
|
||||
/** 로그 식별용 라벨 */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_RETRIES = 2;
|
||||
const DEFAULT_BASE_DELAY_MS = 300;
|
||||
const DEFAULT_MAX_DELAY_MS = 2000;
|
||||
const DEFAULT_TIMEOUT_MS = 15000;
|
||||
|
||||
/**
|
||||
* 취소(AbortError) 여부를 판별합니다.
|
||||
*
|
||||
* `instanceof DOMException` 을 쓰지 않는 이유: jsdom 등 테스트 환경에서 DOMException 이
|
||||
* 없거나 다른 realm 의 것일 수 있어 판정이 어긋난다. name 비교가 환경 독립적이다.
|
||||
*
|
||||
* @param error 검사할 에러
|
||||
* @return bool 취소로 인한 에러이면 true
|
||||
* @since engine-v1.53.0
|
||||
*/
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return (error as { name?: string } | null)?.name === 'AbortError';
|
||||
}
|
||||
|
||||
/**
|
||||
* 네트워크 레벨 실패(응답 없음) 여부를 판별합니다.
|
||||
*
|
||||
* fetch 스펙상 네트워크 오류는 TypeError 로 reject 된다. 취소(AbortError)는
|
||||
* 호출부의 의도이므로 재시도 대상이 아니다.
|
||||
*
|
||||
* @param error 검사할 에러
|
||||
* @return bool 재시도할 가치가 있는 네트워크 실패이면 true
|
||||
* @since engine-v1.53.0
|
||||
*/
|
||||
export function isNetworkFailure(error: unknown): boolean {
|
||||
if (isAbortError(error)) return false;
|
||||
return error instanceof TypeError;
|
||||
}
|
||||
|
||||
let unloading = false;
|
||||
let unloadGuardInstalled = false;
|
||||
|
||||
/**
|
||||
* 현재 문서가 이탈(새로고침/이동) 중인지 반환합니다.
|
||||
*
|
||||
* @return bool 이탈 중이면 true
|
||||
* @since engine-v1.53.0
|
||||
*/
|
||||
export function isDocumentUnloading(): boolean {
|
||||
return unloading;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 이탈 감지 가드를 설치합니다 (중복 설치 무해).
|
||||
*
|
||||
* 이탈 중 발생한 요청 실패는 "버려지는 문서" 의 실패이므로 에러 화면을 띄우면 안 된다.
|
||||
* 사용자가 이미 떠난 화면에 에러를 그리는 것은 그 자체가 결함이다.
|
||||
*
|
||||
* `visibilitychange`/`hidden` 은 의도적으로 쓰지 않는다 — 탭 전환·앱 백그라운드로도
|
||||
* 발화하므로, 백그라운드에서 재시도가 모두 실패한 **정상적 초기화 실패**까지 삼키면
|
||||
* 사용자가 돌아왔을 때 영구 빈 화면이 된다(현행보다 나쁨). 새로고침·이탈은 `pagehide`
|
||||
* 가 반드시 발화한다.
|
||||
*
|
||||
* @return void
|
||||
* @since engine-v1.53.0
|
||||
*/
|
||||
export function installUnloadGuard(): void {
|
||||
if (typeof window === 'undefined' || unloadGuardInstalled) return;
|
||||
unloadGuardInstalled = true;
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
unloading = true;
|
||||
});
|
||||
|
||||
// bfcache 복귀 시 해제 — 없으면 뒤로가기로 복귀한 문서가 영구히 "이탈 중" 으로 오판된다.
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if ((event as PageTransitionEvent).persisted) {
|
||||
unloading = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Safari 보강 (pagehide 미발화 케이스 대비)
|
||||
window.addEventListener('beforeunload', () => {
|
||||
unloading = true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 전용 — unload 가드 상태를 초기화합니다.
|
||||
*
|
||||
* @return void
|
||||
* @since engine-v1.53.0
|
||||
*/
|
||||
export function resetUnloadGuardForTesting(): void {
|
||||
unloading = false;
|
||||
unloadGuardInstalled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지수 백오프 대기 시간을 계산합니다 (2배 증가 + ±25% jitter).
|
||||
*
|
||||
* jitter 를 두는 이유: 병렬 요청이 동시에 실패하면(커넥션 유실은 대개 그렇다)
|
||||
* 재시도가 한 시점에 몰려 스탬피드가 된다.
|
||||
*
|
||||
* @param attempt 완료된 시도 횟수 (0부터)
|
||||
* @param baseDelayMs 기준 대기 시간
|
||||
* @param maxDelayMs 상한
|
||||
* @return number 대기할 밀리초
|
||||
* @since engine-v1.53.0
|
||||
*/
|
||||
function computeBackoffDelay(attempt: number, baseDelayMs: number, maxDelayMs: number): number {
|
||||
const exponential = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
|
||||
const jitter = exponential * 0.25 * (Math.random() * 2 - 1);
|
||||
return Math.max(0, Math.round(exponential + jitter));
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 시간만큼 대기합니다.
|
||||
*
|
||||
* @param ms 대기할 밀리초
|
||||
* @return Promise<void>
|
||||
* @since engine-v1.53.0
|
||||
*/
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 네트워크 레벨 실패에만 재시도하는 fetch 래퍼입니다.
|
||||
*
|
||||
* HTTP 응답(2xx/4xx/5xx 무관)은 **재시도하지 않고 그대로 반환**한다. `!response.ok`
|
||||
* 검사와 throw 는 호출부 책임으로 남긴다 — 유틸이 `.ok` 를 보면 호출부의 상태코드
|
||||
* 분기(예: LayoutLoader 의 401 재시도)와 이중 재시도가 겹친다.
|
||||
*
|
||||
* 외부 signal 에 의한 AbortError 는 즉시 rethrow 한다(호출부의 명시적 취소를 유틸이
|
||||
* 되살리면 안 된다). 내부 timeout 이 발화시킨 abort 만 재시도 대상이다.
|
||||
*
|
||||
* @param url 요청 URL
|
||||
* @param options 재시도 옵션 + fetch init
|
||||
* @return Promise<Response> HTTP 응답 (4xx/5xx 포함)
|
||||
* @throws TypeError 모든 시도가 네트워크 실패로 끝난 경우
|
||||
* @since engine-v1.53.0
|
||||
*/
|
||||
export async function fetchWithRetry(
|
||||
url: string,
|
||||
options: RetryOptions & { init?: RequestInit } = {}
|
||||
): Promise<Response> {
|
||||
const {
|
||||
retries = DEFAULT_RETRIES,
|
||||
baseDelayMs = DEFAULT_BASE_DELAY_MS,
|
||||
maxDelayMs = DEFAULT_MAX_DELAY_MS,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
label = url,
|
||||
init,
|
||||
} = options;
|
||||
|
||||
const maxAttempts = retries + 1;
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
// 내부 timeout 이 발화시킨 abort 와 외부 signal 의 abort 를 구분하기 위한 플래그.
|
||||
// 이 구분이 없으면 호출부가 명시적으로 취소한 요청을 유틸이 되살려 재발사한다.
|
||||
let timedOut = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const controller = timeoutMs > 0 ? new AbortController() : null;
|
||||
|
||||
try {
|
||||
const requestInit: RequestInit = { ...init };
|
||||
|
||||
if (controller) {
|
||||
// 외부 signal 이 있으면 그 취소를 내부 controller 로 전파한다.
|
||||
const externalSignal = init?.signal;
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
controller.abort();
|
||||
} else {
|
||||
externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
|
||||
}
|
||||
}
|
||||
requestInit.signal = controller.signal;
|
||||
timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
const response = await fetch(url, requestInit);
|
||||
|
||||
// HTTP 응답이 왔다 = 재시도 대상 아님. 상태코드 판단은 호출부 몫.
|
||||
return response;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
// 외부 취소는 즉시 rethrow (유틸이 되살리지 않는다)
|
||||
if (isAbortError(error) && !timedOut) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 내부 timeout 이 발화시킨 abort 는 네트워크 실패와 동급으로 취급해 재시도한다.
|
||||
const retryable = timedOut || isNetworkFailure(error);
|
||||
if (!retryable) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 이탈 중인 문서는 남은 재시도를 포기한다 (버려질 문서에 대한 낭비)
|
||||
if (isDocumentUnloading()) {
|
||||
logger.warn(`Document unloading, aborting retries: ${label}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isLastAttempt = attempt === maxAttempts - 1;
|
||||
if (isLastAttempt) {
|
||||
logger.warn(
|
||||
`All ${maxAttempts} attempts failed: ${label}`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const waitMs = computeBackoffDelay(attempt, baseDelayMs, maxDelayMs);
|
||||
logger.warn(
|
||||
`Network failure (attempt ${attempt + 1}/${maxAttempts}), retrying in ${waitMs}ms: ${label}`
|
||||
);
|
||||
await delay(waitMs);
|
||||
} finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 도달 불가 (루프가 반드시 return 또는 throw 한다) — 타입 완결용
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* `<script src>` 로드를 재시도하는 로더입니다.
|
||||
*
|
||||
* 최종 실패 시 **반드시 reject** 한다. `onerror` 에서 `resolve()` 하면 실패가 성공으로
|
||||
* 위장되어 상위 try/catch 가 무력화되고, 앱은 한참 뒤 엉뚱한 곳(미등록 핸들러 등)에서
|
||||
* 죽는다 — 그것이 이 유틸이 도입된 계기다.
|
||||
*
|
||||
* `<script>` 의 `onerror` 는 실패 사유를 주지 않으므로(404 인지 네트워크 유실인지 구분
|
||||
* 불가) **모든 실패를 재시도 대상**으로 본다. 404 라면 3회 시도 후 reject 되어 명시적
|
||||
* 에러로 끝나므로 안전하다.
|
||||
*
|
||||
* 재시도 전 기존 element 를 제거하고 새로 만든다 — 남겨두면 IIFE 번들이 두 번 실행되어
|
||||
* 핸들러가 중복 등록된다.
|
||||
*
|
||||
* @param url 스크립트 URL
|
||||
* @param attrs script element 에 부여할 속성 (id 등)
|
||||
* @param options 재시도 옵션
|
||||
* @return Promise<void> 로드 성공 시 resolve
|
||||
* @throws Error 모든 시도가 실패한 경우
|
||||
* @since engine-v1.53.0
|
||||
*/
|
||||
export async function loadScriptWithRetry(
|
||||
url: string,
|
||||
attrs: Record<string, string> = {},
|
||||
options: RetryOptions = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
retries = DEFAULT_RETRIES,
|
||||
baseDelayMs = DEFAULT_BASE_DELAY_MS,
|
||||
maxDelayMs = DEFAULT_MAX_DELAY_MS,
|
||||
label = url,
|
||||
} = options;
|
||||
|
||||
const maxAttempts = retries + 1;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
await loadScriptOnce(url, attrs);
|
||||
return;
|
||||
} catch (error) {
|
||||
// 실패한 element 를 제거한다 (IIFE 중복 실행 방지 + 다음 시도의 id 충돌 방지)
|
||||
if (attrs.id) {
|
||||
document.getElementById(attrs.id)?.remove();
|
||||
}
|
||||
|
||||
if (isDocumentUnloading()) {
|
||||
logger.warn(`Document unloading, aborting script retries: ${label}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isLastAttempt = attempt === maxAttempts - 1;
|
||||
if (isLastAttempt) {
|
||||
logger.warn(`All ${maxAttempts} script attempts failed: ${label}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const waitMs = computeBackoffDelay(attempt, baseDelayMs, maxDelayMs);
|
||||
logger.warn(
|
||||
`Script load failed (attempt ${attempt + 1}/${maxAttempts}), retrying in ${waitMs}ms: ${label}`
|
||||
);
|
||||
await delay(waitMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `<script>` 1회 로드를 시도합니다 (재시도 없음).
|
||||
*
|
||||
* @param url 스크립트 URL
|
||||
* @param attrs script element 속성
|
||||
* @return Promise<void> 로드 성공 시 resolve, 실패 시 reject
|
||||
* @since engine-v1.53.0
|
||||
*/
|
||||
function loadScriptOnce(url: string, attrs: Record<string, string>): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.async = false; // 번들 내부 물리 순서로 실행 보장
|
||||
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (key === 'id') {
|
||||
script.id = value;
|
||||
} else {
|
||||
script.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error(`Failed to load script: ${url}`));
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
@@ -68,34 +68,24 @@
|
||||
|
||||
@include('partials.template-externals-scripts', ['position' => 'before-core'])
|
||||
|
||||
<!-- 코어 렌더링 엔진 -->
|
||||
<script src="{{ asset('build/core/template-engine.min.js') }}?v={{ filemtime(public_path('build/core/template-engine.min.js')) }}"></script>
|
||||
|
||||
@include('partials.template-externals-scripts', ['position' => 'before-template'])
|
||||
|
||||
<!-- 템플릿 컴포넌트 번들 (IIFE) -->
|
||||
<script src="/api/templates/assets/{{ $activeAdminTemplate }}/js/components.iife.js?v={{ $extensionCacheVersion }}"></script>
|
||||
|
||||
<!-- 템플릿 엔진 초기화 (TemplateApp 사용) -->
|
||||
<script>
|
||||
// TemplateApp을 통한 초기화 (DOMContentLoaded 이벤트에서 자동으로 초기화됨)
|
||||
if (window.G7Core && window.G7Core.initTemplateApp) {
|
||||
window.G7Core.initTemplateApp({
|
||||
templateId: '{{ $activeAdminTemplate }}',
|
||||
templateType: 'admin',
|
||||
locale: '{{ app()->getLocale() }}',
|
||||
debug: {{ config('app.debug') ? 'true' : 'false' }}@if(config('broadcasting.connections.reverb.key')),
|
||||
websocket: {
|
||||
appKey: '{{ config('broadcasting.connections.reverb.key') }}',
|
||||
host: '{{ config('g7.websocket.client.host', config('broadcasting.connections.reverb.options.host', 'localhost')) }}',
|
||||
port: {{ config('g7.websocket.client.port', config('broadcasting.connections.reverb.options.port', 80)) }},
|
||||
scheme: '{{ config('g7.websocket.client.scheme', config('broadcasting.connections.reverb.options.scheme', 'https')) }}'
|
||||
}@endif
|
||||
});
|
||||
} else {
|
||||
console.error('[Admin] G7Core.initTemplateApp is not available');
|
||||
}
|
||||
</script>
|
||||
{{-- 코어 엔진 + 템플릿 컴포넌트 번들 로드 → 초기화 (재시도 + 폴백 UI) --}}
|
||||
@include('partials.bootstrap-scripts', [
|
||||
'templateType' => 'admin',
|
||||
'coreEngineSrc' => asset('build/core/template-engine.min.js') . '?v=' . filemtime(public_path('build/core/template-engine.min.js')),
|
||||
'componentsSrc' => '/api/templates/assets/' . $activeAdminTemplate . '/js/components.iife.js?v=' . $extensionCacheVersion,
|
||||
'initConfig' => array_filter([
|
||||
'templateId' => $activeAdminTemplate,
|
||||
'templateType' => 'admin',
|
||||
'locale' => app()->getLocale(),
|
||||
'debug' => (bool) config('app.debug'),
|
||||
'websocket' => config('broadcasting.connections.reverb.key') ? [
|
||||
'appKey' => config('broadcasting.connections.reverb.key'),
|
||||
'host' => config('g7.websocket.client.host', config('broadcasting.connections.reverb.options.host', 'localhost')),
|
||||
'port' => (int) config('g7.websocket.client.port', config('broadcasting.connections.reverb.options.port', 80)),
|
||||
'scheme' => config('g7.websocket.client.scheme', config('broadcasting.connections.reverb.options.scheme', 'https')),
|
||||
] : null,
|
||||
], fn ($value) => $value !== null),
|
||||
])
|
||||
|
||||
@include('partials.template-externals-scripts', ['position' => 'body-end'])
|
||||
@endif
|
||||
|
||||
@@ -65,34 +65,24 @@
|
||||
|
||||
@include('partials.template-externals-scripts', ['position' => 'before-core'])
|
||||
|
||||
<!-- 코어 렌더링 엔진 -->
|
||||
<script src="{{ asset('build/core/template-engine.min.js') }}?v={{ filemtime(public_path('build/core/template-engine.min.js')) }}"></script>
|
||||
|
||||
@include('partials.template-externals-scripts', ['position' => 'before-template'])
|
||||
|
||||
<!-- 템플릿 컴포넌트 번들 (IIFE) -->
|
||||
<script src="/api/templates/assets/{{ $activeUserTemplate }}/js/components.iife.js?v={{ $extensionCacheVersion }}"></script>
|
||||
|
||||
<!-- 템플릿 엔진 초기화 (TemplateApp 사용) -->
|
||||
<script>
|
||||
// TemplateApp을 통한 초기화 (DOMContentLoaded 이벤트에서 자동으로 초기화됨)
|
||||
if (window.G7Core && window.G7Core.initTemplateApp) {
|
||||
window.G7Core.initTemplateApp({
|
||||
templateId: '{{ $activeUserTemplate }}',
|
||||
templateType: 'user',
|
||||
locale: '{{ app()->getLocale() }}',
|
||||
debug: {{ config('app.debug') ? 'true' : 'false' }}@if(config('broadcasting.connections.reverb.key')),
|
||||
websocket: {
|
||||
appKey: '{{ config('broadcasting.connections.reverb.key') }}',
|
||||
host: '{{ config('g7.websocket.client.host', config('broadcasting.connections.reverb.options.host', 'localhost')) }}',
|
||||
port: {{ config('g7.websocket.client.port', config('broadcasting.connections.reverb.options.port', 80)) }},
|
||||
scheme: '{{ config('g7.websocket.client.scheme', config('broadcasting.connections.reverb.options.scheme', 'https')) }}'
|
||||
}@endif
|
||||
});
|
||||
} else {
|
||||
console.error('[User] G7Core.initTemplateApp is not available');
|
||||
}
|
||||
</script>
|
||||
{{-- 코어 엔진 + 템플릿 컴포넌트 번들 로드 → 초기화 (재시도 + 폴백 UI) --}}
|
||||
@include('partials.bootstrap-scripts', [
|
||||
'templateType' => 'user',
|
||||
'coreEngineSrc' => asset('build/core/template-engine.min.js') . '?v=' . filemtime(public_path('build/core/template-engine.min.js')),
|
||||
'componentsSrc' => '/api/templates/assets/' . $activeUserTemplate . '/js/components.iife.js?v=' . $extensionCacheVersion,
|
||||
'initConfig' => array_filter([
|
||||
'templateId' => $activeUserTemplate,
|
||||
'templateType' => 'user',
|
||||
'locale' => app()->getLocale(),
|
||||
'debug' => (bool) config('app.debug'),
|
||||
'websocket' => config('broadcasting.connections.reverb.key') ? [
|
||||
'appKey' => config('broadcasting.connections.reverb.key'),
|
||||
'host' => config('g7.websocket.client.host', config('broadcasting.connections.reverb.options.host', 'localhost')),
|
||||
'port' => (int) config('g7.websocket.client.port', config('broadcasting.connections.reverb.options.port', 80)),
|
||||
'scheme' => config('g7.websocket.client.scheme', config('broadcasting.connections.reverb.options.scheme', 'https')),
|
||||
] : null,
|
||||
], fn ($value) => $value !== null),
|
||||
])
|
||||
|
||||
@include('partials.template-externals-scripts', ['position' => 'body-end'])
|
||||
@endif
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
{{--
|
||||
코어 엔진 + 템플릿 컴포넌트 번들 부트스트랩 (재시도 + 폴백 UI)
|
||||
|
||||
두 번들은 `<script src>` 로 로드되므로 엔진의 fetch 재시도 래퍼가 닿지 않는다.
|
||||
코어 번들이 없으면 재시도할 JS 자체가 없다(닭-달걀). 따라서 재시도를 **순수 인라인
|
||||
JS** 로 구현한다. 외부 의존성 없이 동작해야 한다.
|
||||
|
||||
로드 실패 시 종전에는 콘솔 에러만 남고 화면은 백지였다. 3회 시도 후에도 실패하면
|
||||
사용자에게 보이는 정적 폴백(새로고침 버튼)을 심는다.
|
||||
|
||||
성능 계약 — `<script src>` 는 **정적 태그로 유지한다**:
|
||||
브라우저의 프리로드 스캐너는 HTML 파싱 중에 정적 `<script src>` 를 미리 발견해
|
||||
번들을 선행 로드한다. 이를 `document.createElement('script')` 로 바꾸면 스캐너가
|
||||
보지 못해 **인라인 JS 가 실행된 뒤에야** 요청이 시작되고, 그만큼 렌더가 늦어진다
|
||||
(실측: HTML 응답 완료 314ms → 코어 번들 요청 시작 568ms, 약 250ms 손실).
|
||||
따라서 정상 경로는 정적 태그 그대로 두고, **실패했을 때만** `onerror` 에서 동적
|
||||
재시도를 건다. 재시도는 예외 경로이므로 스캐너 이점을 잃어도 무방하다.
|
||||
|
||||
'before-template' 외부 스크립트는 코어 뒤 · 템플릿 컴포넌트 앞이라는 순서 계약을
|
||||
갖는다. 정적 태그를 유지하므로 그 사이에 그대로 @include 하면 순서가 지켜진다.
|
||||
|
||||
@param $templateType 'user' | 'admin'
|
||||
@param $coreEngineSrc 코어 엔진 번들 URL
|
||||
@param $componentsSrc 템플릿 컴포넌트 번들 URL
|
||||
@param $initConfig initTemplateApp 에 넘길 설정 (JSON 직렬화 가능)
|
||||
--}}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var MAX_ATTEMPTS = 3; // 총 3시도 (재시도 2회)
|
||||
var BASE_DELAY_MS = 300; // 지수 백오프 기준 (모바일 커넥션 재수립 시간 고려)
|
||||
|
||||
var LABEL = @json($templateType === 'admin' ? '[Admin]' : '[User]');
|
||||
|
||||
// 문서 이탈 추적 — 버려지는 문서에 에러 화면을 그리지 않기 위함
|
||||
window.__g7Unloading = false;
|
||||
window.addEventListener('pagehide', function () { window.__g7Unloading = true; });
|
||||
window.addEventListener('pageshow', function (e) { if (e.persisted) window.__g7Unloading = false; });
|
||||
|
||||
// 부트스트랩 상태 — 정적 <script> 의 onerror/onload 가 여기에 기록한다
|
||||
var bootstrap = window.__g7Bootstrap = {
|
||||
failed: false,
|
||||
pending: 0,
|
||||
|
||||
/**
|
||||
* 정적 <script> 로드 실패 시 동적 재시도를 시작한다.
|
||||
*
|
||||
* <script> 의 onerror 는 실패 사유를 주지 않으므로(404 인지 네트워크 유실인지
|
||||
* 구분 불가) 모든 실패를 재시도한다. 404 라면 3회 후 실패로 끝나므로 안전하다.
|
||||
*
|
||||
* @param src 재시도할 스크립트 URL
|
||||
* @param attempt 현재 시도 번호 (1부터)
|
||||
*/
|
||||
retry: function (src, attempt) {
|
||||
attempt = attempt || 1;
|
||||
|
||||
if (window.__g7Unloading) return;
|
||||
|
||||
if (attempt >= MAX_ATTEMPTS) {
|
||||
console.error(LABEL + ' Failed to load after ' + MAX_ATTEMPTS + ' attempts: ' + src);
|
||||
bootstrap.failed = true;
|
||||
bootstrap.renderFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
var delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
||||
console.warn(LABEL + ' Script load failed (attempt ' + attempt + '/' + MAX_ATTEMPTS + '), retrying in ' + delay + 'ms: ' + src);
|
||||
|
||||
setTimeout(function () {
|
||||
var script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.async = false; // 삽입 순서대로 실행 (코어 → 컴포넌트 순서 보장)
|
||||
script.onload = function () {
|
||||
bootstrap.pending -= 1;
|
||||
bootstrap.tryInit();
|
||||
};
|
||||
script.onerror = function () {
|
||||
if (script.parentNode) script.parentNode.removeChild(script);
|
||||
bootstrap.retry(src, attempt + 1);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}, delay);
|
||||
},
|
||||
|
||||
/**
|
||||
* 부트스트랩 최종 실패 시 사용자에게 보이는 정적 폴백을 심는다.
|
||||
*
|
||||
* 코어 번들이 없을 수 있으므로 템플릿 엔진에 의존하지 않는 순수 DOM + 인라인 스타일.
|
||||
* 종전에는 콘솔에만 기록되어 사용자에게는 영구 백지로 보였다.
|
||||
*/
|
||||
renderFallback: function () {
|
||||
if (window.__g7Unloading) return;
|
||||
|
||||
var app = document.getElementById('app');
|
||||
if (!app) return;
|
||||
if (app.childElementCount > 0) return; // 이미 렌더된 화면은 덮지 않는다
|
||||
|
||||
app.innerHTML =
|
||||
'<div style="min-height:60vh;display:flex;align-items:center;justify-content:center;padding:24px;' +
|
||||
'font-family:system-ui,-apple-system,\'Segoe UI\',sans-serif;">' +
|
||||
'<div style="text-align:center;max-width:420px;">' +
|
||||
'<div style="font-size:40px;line-height:1;margin-bottom:16px;">⚠️</div>' +
|
||||
'<h1 style="font-size:18px;font-weight:600;margin:0 0 8px;color:#111827;">' +
|
||||
@json(__('errors.bootstrap.title')) +
|
||||
'</h1>' +
|
||||
'<p style="font-size:14px;line-height:1.6;margin:0 0 20px;color:#6b7280;">' +
|
||||
@json(__('errors.bootstrap.message')) +
|
||||
'</p>' +
|
||||
'<button type="button" onclick="window.location.reload()" ' +
|
||||
'style="border:0;border-radius:6px;background:#2563eb;color:#fff;font-size:14px;font-weight:500;' +
|
||||
'padding:10px 20px;cursor:pointer;">' +
|
||||
@json(__('errors.bootstrap.reload')) +
|
||||
'</button>' +
|
||||
'</div></div>';
|
||||
},
|
||||
|
||||
/**
|
||||
* 두 번들이 모두 로드된 뒤 엔진을 초기화한다.
|
||||
*
|
||||
* 재시도로 늦게 도착한 경우에도 정확히 1회만 초기화되도록 pending 카운터로 게이트한다.
|
||||
*/
|
||||
tryInit: function () {
|
||||
if (bootstrap.pending > 0 || bootstrap.failed || bootstrap.initialized) return;
|
||||
bootstrap.initialized = true;
|
||||
|
||||
if (window.G7Core && window.G7Core.initTemplateApp) {
|
||||
window.G7Core.initTemplateApp(@json($initConfig));
|
||||
return;
|
||||
}
|
||||
|
||||
// 번들은 받았으나 전역이 없다 = 번들 자체가 손상됨
|
||||
console.error(LABEL + ' G7Core.initTemplateApp is not available');
|
||||
bootstrap.renderFallback();
|
||||
},
|
||||
};
|
||||
|
||||
// 정적 <script> 2개(코어 + 컴포넌트)가 완료돼야 초기화한다
|
||||
bootstrap.pending = 2;
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{-- 코어 렌더링 엔진 — 정적 태그 유지 (프리로드 스캐너가 선행 로드) --}}
|
||||
<script
|
||||
src="{{ $coreEngineSrc }}"
|
||||
onload="window.__g7Bootstrap.pending -= 1; window.__g7Bootstrap.tryInit();"
|
||||
onerror="window.__g7Bootstrap.retry(this.src, 1);"
|
||||
></script>
|
||||
|
||||
@include('partials.template-externals-scripts', ['position' => 'before-template'])
|
||||
|
||||
{{-- 템플릿 컴포넌트 번들 (IIFE) — 정적 태그 유지 --}}
|
||||
<script
|
||||
src="{{ $componentsSrc }}"
|
||||
onload="window.__g7Bootstrap.pending -= 1; window.__g7Bootstrap.tryInit();"
|
||||
onerror="window.__g7Bootstrap.retry(this.src, 1);"
|
||||
></script>
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Smoke: 페이지 로드 요청 1건의 일시 실패가 앱 전체를 죽이지 않는다 (#463)
|
||||
*
|
||||
* 한 페이지 로드는 same-origin 요청 20여 개를 발사하는데, 그중 6개는 1건만 취소돼도
|
||||
* 앱 전체가 죽었다(전면 "초기화 실패" / 백지 / raw 영문 토스트). 모바일 회선에서
|
||||
* 새로고침을 연타하면 요청이 떠 있는 창(수백 ms)에 정확히 꽂혀 간헐 재현된다.
|
||||
*
|
||||
* 방어는 **로드 계층**(fetch 래퍼 / script 로더)에 건다. 재시도할 URL 목록을 열거하는
|
||||
* 방식은 쓰지 않는다 — 레이아웃 API 는 라우트마다 경로가 다른 무한 집합이라, 새 페이지·
|
||||
* 새 모듈이 생길 때마다 구멍이 다시 뚫린다.
|
||||
*
|
||||
* 검증 축:
|
||||
* (A) 1건 취소(일시 실패) → 재시도로 복구, 사용자는 인지조차 못 한다
|
||||
* (B) 상시 취소(진짜 부재) → 백지·raw 영문이 아니라 명시적 안내로 끝난다
|
||||
*
|
||||
* @scenario network-resilience
|
||||
* @effects retry-on-network-failure, no-blank-screen, no-raw-error-leak
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
/** 앱이 죽었다고 판정하는 화면 문구 (전면 에러 / 내부 식별자 raw 노출) */
|
||||
const DEAD_SCREEN = /초기화 실패|페이지 로딩 실패|Unknown action handler/;
|
||||
|
||||
/** 코어/컴포넌트 번들이 끝내 부재할 때 사용자에게 보여야 할 폴백 안내 */
|
||||
const FALLBACK_UI = /화면을 불러오지 못했습니다|Failed to load the page/;
|
||||
|
||||
/**
|
||||
* 지정 패턴의 **첫 요청 1건만** 취소한다 (재시도 요청은 통과).
|
||||
*
|
||||
* 일시적 커넥션 유실을 모사한다 — 재시도가 실제로 복구하는지 보기 위함.
|
||||
*
|
||||
* @param page Playwright page
|
||||
* @param pattern 취소할 요청 URL glob
|
||||
* @return 취소·통과 카운터 (재시도 발동 여부 확인용)
|
||||
*/
|
||||
async function abortFirstRequestOnly(page: Page, pattern: string): Promise<{ hits: () => number }> {
|
||||
let hits = 0;
|
||||
await page.route(pattern, (route) => {
|
||||
hits += 1;
|
||||
if (hits === 1) return route.abort('aborted');
|
||||
return route.continue();
|
||||
});
|
||||
return { hits: () => hits };
|
||||
}
|
||||
|
||||
/** 페이지 본문 텍스트 (공백 정규화) */
|
||||
async function bodyText(page: Page): Promise<string> {
|
||||
return page.evaluate(() => document.body.innerText.trim().replace(/\n+/g, ' | '));
|
||||
}
|
||||
|
||||
test.describe('네트워크 복원력 — 요청 1건의 일시 실패 (#463)', () => {
|
||||
// 각 경로의 첫 요청 1건을 취소해도 재시도로 복구되어 앱이 정상 렌더돼야 한다.
|
||||
const SINGLE_ABORT_PATHS: Array<{ name: string; pattern: string }> = [
|
||||
{ name: 'routes.json', pattern: '**/api/templates/*/routes.json*' },
|
||||
{ name: 'components.json', pattern: '**/api/templates/*/components.json*' },
|
||||
{ name: 'modules/bundle.js', pattern: '**/api/modules/bundle.js*' },
|
||||
{ name: 'layouts/*.json', pattern: '**/api/layouts/**' },
|
||||
];
|
||||
|
||||
for (const { name, pattern } of SINGLE_ABORT_PATHS) {
|
||||
test(`@smoke ${name} 1건 취소 → 재시도로 복구되어 앱이 정상 렌더된다`, async ({ page }) => {
|
||||
const counter = await abortFirstRequestOnly(page, pattern);
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('#app')?.childElementCount ?? 0) > 0,
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
|
||||
const text = await bodyText(page);
|
||||
|
||||
// 전면 에러 화면 없이 살아남아야 한다
|
||||
expect(text).not.toMatch(DEAD_SCREEN);
|
||||
expect(text.length).toBeGreaterThan(20);
|
||||
|
||||
// 재시도가 실제로 발동했는지 — 취소 1건 + 재시도 최소 1건
|
||||
expect(counter.hits()).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
}
|
||||
|
||||
// 레이아웃 API 는 페이지마다 경로가 다르다. 방어가 로드 계층에 걸려 있으므로
|
||||
// URL 이 달라져도 동일하게 복구돼야 한다 (URL 열거 방식이 아님을 잠근다).
|
||||
test('@smoke 레이아웃 json 1건 취소 → 다른 경로의 페이지에서도 복구된다', async ({ page }) => {
|
||||
const counter = await abortFirstRequestOnly(page, '**/api/layouts/**');
|
||||
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('#app')?.childElementCount ?? 0) > 0,
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
|
||||
const text = await bodyText(page);
|
||||
expect(text).not.toMatch(DEAD_SCREEN);
|
||||
expect(counter.hits()).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('네트워크 복원력 — 번들이 끝내 부재할 때 (#463)', () => {
|
||||
/**
|
||||
* 코어 번들이 없으면 재시도할 JS 자체가 없다(닭-달걀). blade 인라인 JS 가 재시도하고,
|
||||
* 끝내 실패하면 **백지가 아니라** 사용자 안내를 심어야 한다.
|
||||
*
|
||||
* 수정 전: 완전 백지 (콘솔에만 `G7Core.initTemplateApp is not available`).
|
||||
*/
|
||||
test('@smoke 코어 번들 상시 부재 → 백지가 아니라 폴백 안내 + 새로고침 버튼', async ({ page }) => {
|
||||
let attempts = 0;
|
||||
await page.route('**/build/core/template-engine.min.js*', (route) => {
|
||||
attempts += 1;
|
||||
return route.abort('failed');
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('#app')?.childElementCount ?? 0) > 0,
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
|
||||
const text = await bodyText(page);
|
||||
|
||||
// 백지가 아니어야 한다 (수정 전 결함)
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
expect(text).toMatch(FALLBACK_UI);
|
||||
await expect(page.locator('#app button')).toBeVisible();
|
||||
|
||||
// 재시도 3시도 (초기 1 + 재시도 2)
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
|
||||
/**
|
||||
* 재시도를 소진한 **진짜 실패**는 조용히 삼키면 안 된다. 사용자가 상황을 알고
|
||||
* 재시도(새로고침)할 수 있어야 한다 — 복원력이 은폐가 되어서는 안 된다.
|
||||
*/
|
||||
test('@smoke routes.json 상시 부재 → 명시적 에러 화면 + 새로고침 버튼', async ({ page }) => {
|
||||
let attempts = 0;
|
||||
await page.route('**/api/templates/*/routes.json*', (route) => {
|
||||
attempts += 1;
|
||||
return route.abort('failed');
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('#app')?.childElementCount ?? 0) > 0,
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
|
||||
const text = await bodyText(page);
|
||||
|
||||
// 재시도 상한(초기 1 + 재시도 2)을 지킨다 — 무한루프 부재
|
||||
expect(attempts).toBe(3);
|
||||
|
||||
// 진짜 실패는 사용자에게 알린다 (백지가 아니라 명시적 에러 + 재시도 수단)
|
||||
expect(text).toMatch(/초기화 실패/);
|
||||
expect(text).toMatch(/새로고침/);
|
||||
});
|
||||
|
||||
/**
|
||||
* 모듈 번들이 부재하면 그 확장 소유 핸들러는 **영원히** 등록되지 않는다.
|
||||
*
|
||||
* 수정 전 결함 2가지:
|
||||
* (a) waitForHandlers 가 maxWait(5000ms) 통째로 블로킹 → 5초 백지
|
||||
* (b) `Unknown action handler: sirsoft-ecommerce.initPreferredCurrency` raw 노출
|
||||
*/
|
||||
test('@smoke 모듈 번들 상시 부재 → 5초 블로킹 없이 렌더 + 내부 식별자 미노출', async ({ page }) => {
|
||||
await page.route('**/api/modules/bundle.js*', (route) => route.abort('failed'));
|
||||
|
||||
// `waitUntil: 'commit'` — 기본값('load')은 취소된 번들의 재시도 체인까지 포함한
|
||||
// 모든 서브리소스를 기다리므로, 측정 시작점이 밀려 "렌더까지 걸린 시간" 이 아니라
|
||||
// "네비게이션 + 렌더" 를 재게 된다. 우리가 잠그려는 것은 waitForHandlers 의
|
||||
// 5초 블로킹이므로, 문서 커밋 시점부터 렌더까지를 잰다.
|
||||
await page.goto('/', { waitUntil: 'commit' });
|
||||
const committedAt = Date.now();
|
||||
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('#app')?.childElementCount ?? 0) > 0,
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
const renderedInMs = Date.now() - committedAt;
|
||||
|
||||
// (a) 5초 블로킹이 사라졌다 — 오지 않을 핸들러를 기다리지 않는다.
|
||||
// 수정 전에는 waitForHandlers 가 maxWait(5000ms) 를 통째로 소진했다.
|
||||
expect(renderedInMs).toBeLessThan(5_000);
|
||||
|
||||
// (b) 내부 식별자가 사용자 화면에 raw 로 노출되지 않는다
|
||||
await page.waitForTimeout(2_000); // 토스트가 뜰 시간을 준 뒤 확인
|
||||
const text = await bodyText(page);
|
||||
expect(text).not.toMatch(/Unknown action handler/);
|
||||
|
||||
// 확장 기능만 조용히 열화되고 페이지 자체는 살아 있다
|
||||
expect(text.length).toBeGreaterThan(20);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user