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:
@@ -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,
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user