fix(engine): 감사 지적 반영 — 언어팩 버전 bump + 회귀 가드 실질화

전수조사에서 지적된 5건을 코드로 재확인하고 조치했다.

차단성:
- ja 언어팩 1.0.3 → 1.0.4. 1.0.3 은 이미 배포된 버전이라, 같은 버전에 키를
 추가하면 기설치 사이트가 업데이트를 받지 못해 번역이 영영 도달하지 않는다.

회귀 가드 실질화 (green ≠ 회귀를 잡는다):
- 미등록 핸들러 억제 테스트가 동어반복이었다. 값을 손으로 대입하고 그 값을
 읽어, 억제를 삭제해도 통과했다. 실제 dispatch 후 표시 계층이 호출되지
 않음을 감시하도록 교체 (억제 제거 시 red 확인).
- script 번들의 "1건 취소 → 복구" E2E 는 수정 전에도 통과한다. 브라우저가
 abort 된 script 를 스스로 재요청하기 때문 (수정 전 blade 로 실측: 재시도
 훅 0개인데 hits=2, 렌더 정상). 거짓 안심이므로 제거하고, blade 가 고유하게
 보장하는 것 — 재시도/폴백 훅 설치 + 정상 경로 오발동 없음 — 을 단언하도록
 교체 (수정 전 red 확인).
- 재시도 횟수 단언을 하한(>=2)에서 정확값(==2)으로. 과잉 재시도 회귀를
 통과시키던 구멍을 막는다.

정확도:
- waitForHandlers 가 전역 실패 플래그로 판정해, 무관한 확장의 실패가 정상
 로드된 확장의 핸들러 대기까지 포기시켰다. 실패 키 단위로 정밀화한다.
This commit is contained in:
HeuJung
2026-07-12 16:30:38 +09:00
parent 68cbee0e83
commit 793afb95dc
8 changed files with 129 additions and 21 deletions
+6 -1
View File
@@ -4,11 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.3] - 2026-07-11
## [1.0.4] - 2026-07-12
### Added
- 화면 구성에 필요한 스크립트를 끝내 불러오지 못했을 때 표시되는 안내 문구 일본어 번역 추가 (`errors.bootstrap.*`) — 네트워크 문제로 페이지를 열지 못한 경우의 안내와 새로고침 버튼이 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.3] - 2026-07-11
### Added
- vendor-bundle 빌드 결과 반영 실패 안내 메시지 일본어 번역 추가 (`exceptions.vendor.bundle_build_promote_failed`) — 빌드 산출물을 최종 경로로 원자적 교체하는 데 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.2] - 2026-07-08
@@ -12,7 +12,7 @@
"en": "G7 core Japanese language pack (bundled)",
"ja": "G7 コア 日本語 言語パック(バンドル)"
},
"version": "1.0.3",
"version": "1.0.4",
"license": "MIT",
"scope": "core",
"target_identifier": null,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+30 -6
View File
@@ -4188,14 +4188,38 @@ export class TemplateApp {
return;
}
// 확장 JS 번들 로드가 실패로 확정됐다면 그 핸들러는 영원히 오지 않는다.
// 대기 중인 핸들러를 실어올 JS 가 **실패로 확정**됐다면 그 핸들러는 영원히 오지 않는다.
// 오지 않을 것을 기다리지 않는다 (5초 백지 제거).
if (getModuleAssetLoader().hasFailedJsAssets()) {
logger.warn(
'Extension asset load failed — not waiting for handlers that will never register:',
handlerNames
//
// 실패 키는 두 형태다:
// - 병합 번들 경로: 'module' / 'plugin' (확장 전체가 한 파일)
// - 개별 로딩 경로: 확장 식별자 (예: 'sirsoft-ecommerce')
// 핸들러 이름은 `{확장식별자}.{핸들러}` 이므로, 개별 로딩 실패는 접두사로 대조한다.
// 병합 번들이 죽으면 그 번들에 속한 확장을 여기서 알 수 없으므로 전체 포기가 맞다
// (그 파일 하나가 통째로 없다).
//
// 반대로 실패를 특정 핸들러에 귀속시킬 수 없으면 **기다린다** — 무관한 확장의 실패로
// 정상 로드 중인 확장의 핸들러 대기까지 포기하면 멀쩡한 기능이 조용히 사라진다.
// (@since engine-v1.53.0)
const failedAssets = getModuleAssetLoader().getFailedJsAssets();
if (failedAssets.length > 0) {
const bundleFailed = failedAssets.some(key => key === 'module' || key === 'plugin');
const pending = handlerNames.filter(
name => !actionDispatcher.customHandlers?.has(name)
);
return;
const allPendingAreDead =
pending.length > 0 &&
pending.every(name =>
failedAssets.some(key => name.startsWith(`${key}.`))
);
if (bundleFailed || allPendingAreDead) {
logger.warn(
'Extension asset load failed — not waiting for handlers that will never register:',
{ pending, failedAssets }
);
return;
}
}
logger.log('Waiting for module handlers:', handlerNames);
@@ -28,7 +28,7 @@
#### 확장 번들 부재 시 5초 백지 + 내부 식별자 raw 노출
- `TemplateApp.ts::waitForHandlers` — 확장 JS 번들 로드가 **실패로 확정**된 경우 `maxWait`(5000ms) 를 기다리지 않고 즉시 반환한다. 종전에는 레이아웃 `init_actions` 가 참조하는 확장 소유 핸들러가 영원히 등록되지 않는데도 5초간 폴링했고, 그동안 렌더가 시작되지 않아 사용자에게는 백지로 보였다.
- `TemplateApp.ts::waitForHandlers` — 확장 JS 번들 로드가 **실패로 확정**된 경우 `maxWait`(5000ms) 를 기다리지 않고 즉시 반환한다. 종전에는 레이아웃 `init_actions` 가 참조하는 확장 소유 핸들러가 영원히 등록되지 않는데도 5초간 폴링했고, 그동안 렌더가 시작되지 않아 사용자에게는 백지로 보였다. 판정은 실패 키 단위로 한다 — 병합 번들(`module`/`plugin`) 이 죽으면 그 파일에 속한 확장을 알 수 없으므로 대기를 포기하고, 개별 로딩 실패는 `{확장식별자}.{핸들러}` 접두사로 대조해 **그 확장의 핸들러만** 포기한다. 실패를 특정 핸들러에 귀속시킬 수 없으면 기다린다 — 무관한 확장의 실패로 정상 로드 중인 확장의 기능까지 조용히 사라지면 안 된다.
- `ActionDispatcher.ts` — 미등록 핸들러로 인한 `ActionError` 에 `unknownHandler` 플래그를 부여하고, 표시 계층에서 `errorHandling` 정책(토스트 등)을 태우지 않는다. 확장 번들이 없으면 `Unknown action handler: sirsoft-ecommerce.initPreferredCurrency` 같은 **내부 식별자가 담긴 raw 영문 문구**가 사용자에게 토스트로 노출됐다. 확장 부재는 사용자가 조치할 수 있는 일이 아니므로 조용한 기능 열화로 끝낸다. `throw` 자체는 유지해 호출부의 기존 흐름은 바뀌지 않으며, 이 완화는 미등록 핸들러에 한정하고 다른 액션 실패는 종전대로 표시한다.
### Changed
@@ -335,16 +335,53 @@ describe('[사례 3] 확장 부재 시 5초 백지 + 내부 식별자 raw 노출
* 빨간 토스트로 사용자에게 raw 노출된다.
* 해결: 미등록 핸들러 에러를 플래그로 식별해 표시 계층(errorHandling 정책)을 태우지 않는다.
*/
it('미등록 핸들러 에러는 unknownHandler 로 식별되어 표시 계층에서 걸러진다', async () => {
it('미등록 핸들러를 실제로 dispatch 하면 unknownHandler 로 표시되고 토스트가 뜨지 않는다', async () => {
const { ActionDispatcher, ActionError } = await import('../ActionDispatcher');
const dispatcher = new ActionDispatcher();
// 확장 번들이 로드되지 않아 그 확장 소유 핸들러가 등록되지 않은 상황
expect((dispatcher as any).customHandlers?.has('sirsoft-ecommerce.initPreferredCurrency')).toBeFalsy();
// 표시 계층 감시자 — ErrorHandlingResolver.
//
// 미등록 핸들러의 에러는 이 resolver 를 **태우지 않고** 곧장 빠져나가야 한다.
// resolver 가 돌면 errorHandling 정책(토스트 등)이 결정·실행되어
// `Unknown action handler: sirsoft-ecommerce.initPreferredCurrency` 라는
// 내부 식별자가 담긴 raw 영문 문구가 사용자에게 노출된다.
//
// resolver 호출 여부가 곧 "표시 계층을 탔는가" 이므로 이를 직접 감시한다.
const resolverModule = await import('../../error');
const resolveSpy = vi.spyOn(
resolverModule.getErrorHandlingResolver(),
'resolve'
);
const result: any = await dispatcher.dispatchAction(
{
handler: 'sirsoft-ecommerce.initPreferredCurrency',
// 표시 계층을 태우라고 명시해도 미등록 핸들러는 예외로 걸러져야 한다
errorHandling: { default: { toast: true } },
} as any,
{} as any
);
// 실패 사실과 그 사유(미등록 핸들러)는 호출부에 그대로 전달된다 — 삼키지 않는다.
expect(result.success).toBe(false);
expect(result.error).toBeInstanceOf(ActionError);
expect(result.error.unknownHandler).toBe(true);
// 핵심 단언: 표시 계층(ErrorHandlingResolver) 이 실행되지 않았다
// = 내부 식별자가 사용자에게 노출되지 않았다.
expect(resolveSpy).not.toHaveBeenCalled();
});
it('미등록 핸들러가 아닌 일반 액션 에러는 종전대로 표시 계층으로 넘어간다 (과잉 억제 방지)', 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);
});
});
@@ -74,11 +74,53 @@ test.describe('네트워크 복원력 — 요청 1건의 일시 실패 (#463)',
expect(text).not.toMatch(DEAD_SCREEN);
expect(text.length).toBeGreaterThan(20);
// 재시도가 실제로 발동했는지 — 취소 1건 + 재시도 최소 1건
expect(counter.hits()).toBeGreaterThanOrEqual(2);
// 취소 1건 + 재시도 1건 = 정확히 2회.
// 하한(>=2)으로 두면 과잉 재시도(3회+) 회귀가 그대로 통과한다. 재시도는 "필요한
// 만큼만" 이 계약이므로 정확값으로 잠근다.
expect(counter.hits()).toBe(2);
});
}
/**
* `<script src>` 경로 (코어 번들 / 템플릿 컴포넌트 번들).
*
* 실측 결과 — 이 둘은 **1건 취소만으로는 죽지 않는다**. Chromium 이 abort 된
* `<script src>` 를 스스로 재요청하기 때문이다(수정 전 blade 로 측정해도
* hits=2, #app 렌더 정상). 따라서 "1건 취소 → 복구" 를 단언하는 테스트는
* **수정 전에도 통과** 해 회귀 가드가 되지 못한다 (거짓 안심).
*
* blade 인라인 재시도가 실제로 값을 갖는 지점은 **브라우저가 포기한 뒤**다.
* 그 경계(끝내 부재 → 백지 대신 폴백 안내)는 아래 '번들이 끝내 부재할 때'
* describe 가 검증한다. 여기서는 그 사이 계약 — 재시도 훅이 실제로 설치되고
* 정상 경로를 방해하지 않는다는 것 — 만 잠근다.
*/
test('@smoke script 번들 경로 — 부트스트랩 재시도 훅이 설치되고 정상 로드를 방해하지 않는다', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.waitForFunction(
() => (document.querySelector('#app')?.childElementCount ?? 0) > 0,
{ timeout: 20_000 }
);
// 재시도/폴백 훅이 실제로 살아 있어야 한다 (blade partial 이 빠지면 여기서 잡힌다)
const bootstrap = await page.evaluate(() => {
const b = (window as any).__g7Bootstrap;
return b
? { present: true, failed: b.failed, hasRetry: typeof b.retry === 'function', hasFallback: typeof b.renderFallback === 'function' }
: { present: false };
});
expect(bootstrap.present).toBe(true);
expect(bootstrap.hasRetry).toBe(true);
expect(bootstrap.hasFallback).toBe(true);
// 정상 경로에서는 실패로 마킹되지 않아야 한다 (재시도가 오발동하면 여기서 잡힌다)
expect(bootstrap.failed).toBe(false);
const text = await bodyText(page);
expect(text).not.toMatch(DEAD_SCREEN);
});
// 레이아웃 API 는 페이지마다 경로가 다르다. 방어가 로드 계층에 걸려 있으므로
// URL 이 달라져도 동일하게 복구돼야 한다 (URL 열거 방식이 아님을 잠근다).
test('@smoke 레이아웃 json 1건 취소 → 다른 경로의 페이지에서도 복구된다', async ({ page }) => {
@@ -93,7 +135,7 @@ test.describe('네트워크 복원력 — 요청 1건의 일시 실패 (#463)',
const text = await bodyText(page);
expect(text).not.toMatch(DEAD_SCREEN);
expect(counter.hits()).toBeGreaterThanOrEqual(2);
expect(counter.hits()).toBe(2);
});
});