fix(engine): 구형 Safari 부팅 실패 해소 및 배포 번들 브라우저 하한 회귀 방지

코어 엔진 번들의 정규식 lookbehind 2건이 Safari 16.4 미만에서 파싱 자체를
거부당해 iOS 15 대 전 기기에서 SPA 가 통째로 뜨지 않았다. 정규식 리터럴은
스크립트 파싱 단계에서 검증되므로 그 문법을 모르는 엔진은 파일을 한 줄도
실행하지 못하고, 번들이 async/defer 없는 동기 classic 스크립트라 폴백 경로도
없었다. 빌드 타깃(es2020)이 이를 막지 못한 이유는 ES 연도와 브라우저 지원
연도가 다르기 때문이며, 정규식 리터럴 문법은 다운레벨 자체가 불가능해
어떤 타깃 설정으로도 걸러지지 않는다.

- 두 정규식을 lookbehind 없는 형태로 교체(engine-v1.60.6). 선행 따옴표·구분자를
 소비한 뒤 캡처 그룹으로 분기하며, 식별자 인덱스가 match[1] → match[2] 로
 이동한다. 무작위 200,034 표본 퍼징에서 교체 전후 결과 불일치 0건.
 파싱 하한 실측 Safari 16.4 → 14.1 (제보자 기기 15.6 포함).

- 부팅 실패 안내를 사유별로 가른다. 번들을 받았는데 실행되지 않은 경우와
 끝내 받지 못한 경우는 사용자가 취할 행동이 정반대인데 같은 문구였다.
 사유는 추측이 아니라 window error 이벤트로 SyntaxError 를 관측해 판정한다
 (파싱 실패 시 script 는 error 가 아니라 load 를 발생시켜 element 이벤트로는
 구분 불가). 비호환 분기는 새로고침해도 낫지 않으므로 버튼을 렌더하지 않는다.
 인라인 부트스트랩은 ES5 를 유지해야 이 안내 자체가 구형에서 살아남는다.

- 재발 방지: 배포 JS 산출물의 브라우저 하한 초과 문법·API 를 정적 검사로 차단.
 부팅 임계 번들(코어 엔진·템플릿 컴포넌트)은 파싱에 실패하면 안내 화면조차
 렌더되지 않으므로 하한과 같은 버전을 요구하는 정규식 리터럴 문법도 금지한다.
 lookbehind 의 Safari 16.4 는 하한과 정확히 같아 하한 초과만 보는 검사로는
 영원히 잡히지 않았다 — 그 지점이 이번 사각이었다.

- 지원 브라우저 최소 버전(Chrome 111 / Safari 16.4 / Firefox 128)과, 하한 미만
 브라우저에서도 화면 표시와 기본 이용은 유지한다는 방침을 문서에 명시.

부수 수정: network-resilience E2E 4건이 자산 URL extensionless 모드에서 URL
glob 이 한 건도 매칭되지 않아 실패하고 있었다. 같은 파일의 컴포넌트 번들
테스트만 이중 모드를 알고 형제 케이스는 몰랐던 불균형이라 매처를 통일했다.
This commit is contained in:
HeuJung
2026-08-21 20:55:57 +09:00
parent 80d16c80bc
commit 0db8f9c5fb
16 changed files with 685 additions and 32 deletions
+15
View File
@@ -1126,6 +1126,21 @@ php artisan language-pack:update g7-core-ja --force
번들 언어팩도 `_bundled` 는 배포 원본일 뿐이다. 설치본(`lang-packs/{id}/`)을 갱신하지 않으면 새로 추가한 번역 키가 런타임에 존재하지 않아 해당 로케일이 조용히 기준 로케일로 폴백한다.
### 배포 산출물의 브라우저 하한
선언 하한은 **Chrome 111 / Safari 16.4 / Firefox 128** 이다 ([requirements.md §7](docs/requirements.md)). 빌드 타깃(`target: 'es2020'`)은 이 하한을 강제하지 못한다 — **ES 연도와 브라우저 지원 연도가 다르기 때문**이다. ES2018 인 정규식 lookbehind 를 WebKit 은 Safari 16.4 에서야 구현했고, 타깃 검사는 그대로 통과시킨다.
정규식 **리터럴** 문법은 그중에서도 다운레벨이 원리상 불가능하다. 번들러는 lookbehind 를 `new RegExp(...)` 로 옮길 뿐이라 파싱 오류가 **런타임 오류로 이동**할 뿐 사라지지 않는다. 따라서 타깃 하향은 해법이 아니다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 배포 JS 산출물에 선언 하한 **초과** 문법·API (`Object.groupBy`·`Promise.withResolvers`·`Array.fromAsync`·`RegExp.escape`·정규식 `v` 플래그) | 하한 이하 문법으로 작성 |
| **부팅 임계 번들**(`public/build/core/template-engine.min.js`, `templates/_bundled/*/dist/js/components.iife.js`)에 정규식 리터럴 전용 문법(lookbehind `(?<!` `(?<=`, `v` 플래그) — 하한과 같은 버전이어도 | 그 두 파일만은 하한 미만 브라우저에서도 **파싱**돼야 한다 |
| 빌드 타깃을 낮춰 해결 시도 | 소스에서 그 문법을 쓰지 않는다 |
| 바이트 길이 비교식 번들러 검출기로 판정 | 문법·API 표 기반 검사 (프린터 표기 차이가 오탐을 낸다) |
부팅 임계 번들 둘은 `async`/`defer` 없는 동기 classic 스크립트다. 파싱에 실패하면 **폴백 안내 화면조차 렌더되지 않아** 사용자에게는 백지 또는 거짓 진단만 남는다. 하한 미만 브라우저에 "지원 범위 밖" 안내를 띄우려면 이 두 파일은 파싱에 성공해야 하므로, 하한과 **정확히 같은** 버전을 요구하는 문법(lookbehind = Safari 16.4)도 금지한다 — 하한 초과만 보는 검사로는 영원히 잡히지 않는 지점이다. 정적 검사가 이 규칙을 강제한다.
### 코어 3-번들 구조 + 공유 런타임 (engine-v1.51.0+)
`core:build` 는 코어 프론트엔드를 3개 IIFE 번들로 빌드한다:
+3
View File
@@ -16,9 +16,12 @@
- 비활성화한 플러그인의 기능이 더 이상 동작하지 않습니다. 이전에는 플러그인을 꺼도 화면·메뉴·스크립트만 사라지고 그 플러그인의 기능 주소는 계속 응답해, 꺼진 결제수단으로 결제가 시도되는 등 "껐는데 아직 살아 있는" 상태가 남았습니다. 이제 모듈과 동일하게 활성화된 플러그인의 기능만 동작합니다. **결제·본인인증처럼 외부 서비스가 직접 호출하는 주소도 함께 닫히므로, 진행 중인 거래가 있을 때의 비활성화·업데이트는 처리가 끝난 뒤에 하시기 바랍니다.**
- 확장 개발자용: 모듈·플러그인의 설치·활성화·비활성화·제거 처리에서 실패 사유를 코어에 전달할 수 있습니다. `failWith()` 로 사유를 남기고 실패를 반환하면 관리자 화면의 실패 안내에 그 사유가 함께 표시됩니다. 사유를 남기지 않아도 종전처럼 동작합니다.
- 지원 브라우저 문서에 최소 버전(Chrome 111 / Safari 16.4 / Firefox 128)을 명시했습니다. 이보다 오래된 브라우저에서는 스타일이 일부 깨질 수 있으나 화면 표시와 기본 이용은 가능하도록 유지한다는 방침도 함께 밝혔습니다. (#121 @bigmsg 님께서 건의해주셨습니다.)
### Fixed
- 구형 iOS·macOS Safari(16.4 미만)에서 사이트가 전혀 표시되지 않던 문제를 수정했습니다. 화면 구성에 쓰이는 스크립트에 해당 브라우저가 해석하지 못하는 문법이 들어 있어, 스크립트 전체가 실행되지 못하고 사이트가 통째로 멈춰 있었습니다. (#121 @bigmsg 님께서 제보해주셨습니다.)
- 화면을 불러오지 못했을 때 원인과 무관하게 "네트워크 연결이 불안정할 수 있습니다"로 안내되던 문제를 수정했습니다. 브라우저가 지원 범위보다 오래되어 화면을 실행하지 못한 경우에는 그에 맞는 안내를 표시하며, 새로고침해도 해결되지 않는 상황이므로 새로고침 버튼도 표시하지 않습니다.
- 관리자 화면의 실패 안내에 원인이 들어갈 자리가 채워지지 않아 `:error` 라는 내부 표시가 그대로 보이던 문제를 수정했습니다. 모듈·플러그인·템플릿·언어팩 관리와 플러그인 설정 저장의 실패 안내 전반에서 발생했습니다. 이제 실패 원인을 알 수 있으면 그 원인이, 알 수 없으면 일반 안내 문구가 표시됩니다. 언어팩 관리처럼 원인을 표시하지 않기로 한 안내는 문구 자체를 정리했습니다.
- 모듈·플러그인을 활성화한 직후 그 확장의 화면과 기능이 "주소를 찾을 수 없음" 오류만 내던 문제를 수정했습니다. 활성화 시점에 사이트 내부 주소록이 활성화 이전 상태를 기준으로 다시 만들어져, 방금 켠 확장의 주소가 빠진 채로 굳어졌습니다. 시간이 지나도 스스로 복구되지 않았습니다. 같은 원인으로 비활성화한 확장의 기능이 계속 호출 가능하던 문제, 확장을 업데이트한 직후 그 확장의 기능과 다른 기능과의 연동 동작이 함께 누락되던 문제도 바로잡았습니다.
+15 -7
View File
@@ -320,15 +320,23 @@ Composer 설치 방식을 선택할 때만 필요하다.
## 7. 지원 브라우저
| 브라우저 | 지원 기준 | 검증 방식 |
|---------|----------|----------|
| Chrome / Edge | React 19 + Tailwind CSS 4 호환 범위 | 자동 브라우저 테스트(Chromium)로 상시 검증 |
| Firefox | React 19 + Tailwind CSS 4 호환 범위 | 호환 범위 기준 지원 (상시 자동 테스트 대상 아님) |
| Safari | React 19 + Tailwind CSS 4 호환 범위 | 호환 범위 기준 지원 (상시 자동 테스트 대상 아님, 실기기 검증 미수행) |
| 브라우저 | 최소 지원 버전 | 검증 방식 |
|---------|--------------|----------|
| Chrome / Edge | **111** 이상 | 자동 브라우저 테스트(Chromium)로 상시 검증 |
| Safari (macOS / iOS) | **16.4** 이상 | 호환 범위 기준 지원 (상시 자동 테스트 대상 아님, 실기기 검증 미수행) |
| Firefox | **128** 이상 | 호환 범위 기준 지원 (상시 자동 테스트 대상 아님) |
- 지원 하한은 프론트엔드 핵심 의존성(React 19, Tailwind CSS 4)의 공식 지원 브라우저
범위를 따르며, 해당 의존성 버전이 변경되면 이 기준도 함께 재검토한다
- 참고: 2026-08 현재 Tailwind CSS 4 의 공식 하한은 Chrome 111 / Safari 16.4 / Firefox 128 이다
범위를 따른다. 위 세 버전은 2026-08 현재 Tailwind CSS 4 의 공식 하한이며(`@property`
등 CSS 기능이 결정한다), 해당 의존성 버전이 변경되면 이 기준도 함께 재검토한다
- **하한 미만 브라우저에서도 화면 표시와 기본 이용은 가능하도록 유지한다.** 최신 CSS 기능을
해석하지 못해 색상·간격 등 스타일이 일부 깨질 수 있으나, 그것이 이용 불가로 이어지지
않도록 한다. 이를 위해 배포되는 JavaScript 산출물에는 하한을 넘는 문법·API 를 넣지 않으며,
특히 부팅에 필요한 번들에는 다운레벨이 불가능한 정규식 리터럴 문법(lookbehind, `v` 플래그)
을 하한과 같은 버전이라도 사용하지 않는다 — 이 번들이 파싱되지 않으면 사이트가 통째로
뜨지 않기 때문이다
- 브라우저가 화면 구성 스크립트를 끝내 실행하지 못하는 경우에는 백지 대신 그 사유를 밝히는
안내 화면을 표시한다
- 자동 테스트는 테스트 도구에 포함된 단일 브라우저 빌드로 수행하므로 특정 버전
목록(예: "최신 N개 버전")을 상시 보장하지 않는다
- Internet Explorer 미지원
@@ -10,6 +10,7 @@
- 아웃바운드 프록시 설정의 검증 메시지와 항목명 일본어 번역을 추가했습니다 (`settings.outbound_proxy_*`, `attributes.outbound_proxy*`).
- 아웃바운드 프록시 연결 테스트 결과 안내 문구의 일본어 번역을 추가했습니다 (`settings.outbound_proxy_test_*`).
- 브라우저가 지원 범위보다 오래되어 화면 구성 스크립트를 실행하지 못한 경우의 안내 문구 일본어 번역을 추가했습니다 (`errors.bootstrap.incompatible_title`, `errors.bootstrap.incompatible_message`).
### Changed
@@ -31,5 +31,7 @@ return [
'title' => '画面を読み込めませんでした',
'message' => 'ネットワーク接続が不安定な可能性があります。ページを更新してからもう一度お試しください。',
'reload' => '更新',
'incompatible_title' => 'このブラウザでは画面を表示できません',
'incompatible_message' => 'ブラウザが古いため、サイトを実行できませんでした。ブラウザを最新バージョンに更新するか、別のブラウザでアクセスしてください。',
],
];
+5
View File
@@ -44,5 +44,10 @@ return [
'title' => 'Failed to load the page',
'message' => 'Your network connection may be unstable. Please refresh and try again.',
'reload' => 'Refresh',
// 스크립트를 받았으나 브라우저가 실행하지 못한 경우 (지원 범위보다 오래된 브라우저 등).
// 새로고침해도 낫지 않으므로 이 분기에서는 새로고침 버튼을 렌더하지 않는다.
'incompatible_title' => 'This browser cannot display the page',
'incompatible_message' => 'Your browser is too old to run this site. Please update it to the latest version, or try a different browser.',
],
];
+5
View File
@@ -44,5 +44,10 @@ return [
'title' => '화면을 불러오지 못했습니다',
'message' => '네트워크 연결이 불안정할 수 있습니다. 새로고침 후 다시 시도해 주세요.',
'reload' => '새로고침',
// 스크립트를 받았으나 브라우저가 실행하지 못한 경우 (지원 범위보다 오래된 브라우저 등).
// 새로고침해도 낫지 않으므로 이 분기에서는 새로고침 버튼을 렌더하지 않는다.
'incompatible_title' => '이 브라우저에서는 화면을 표시할 수 없습니다',
'incompatible_message' => '브라우저가 오래되어 사이트를 실행하지 못했습니다. 브라우저를 최신 버전으로 업데이트하거나 다른 브라우저로 접속해 주세요.',
],
];
File diff suppressed because one or more lines are too long
@@ -5,6 +5,18 @@
>
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
## [engine-v1.60.6] - 2026-08-21
### Fixed
#### 정규식 lookbehind 로 인한 구형 Safari 전면 부팅 실패
- `DataBindingEngine` 의 정규식 2건(`preprocessTranslationTokens` · `extractVariablesFromExpression`)이 ES2018 lookbehind(`(?<!...)`)를 쓰고 있었다. 정규식 **리터럴**은 스크립트 파싱 단계에서 검증되므로, lookbehind 를 모르는 엔진(WebKit 은 Safari 16.4 에서 구현)은 코어 엔진 번들을 **한 줄도 실행하지 못한다.** 번들은 `async`/`defer` 없는 동기 classic 스크립트라 폴백 경로도 없어, iOS 15 대 기기에서 사이트가 통째로 뜨지 않았다.
- 두 정규식을 lookbehind 없는 형태로 교체했다 — 선행 따옴표/구분자를 **소비**한 뒤 캡처 그룹으로 분기한다. 식별자 패턴은 구분자가 `match[1]`, 식별자명이 `match[2]` 로 이동했다.
- 동작은 완전히 동일하다: 무작위 200,034 표본(손으로 고른 경계 케이스 34건 포함) 퍼징에서 교체 전/후 결과 불일치 0건.
- 이 2건이 유일한 병목이었다 — 제거 후 코어 엔진 번들의 파싱 하한이 Safari 16.4 → 14.1 로 내려간다(esbuild 타깃 판정). 빌드 타깃 하향은 해법이 아니다: esbuild 는 lookbehind 를 `new RegExp(...)` 로 옮길 뿐이라 파싱 오류가 **런타임 오류로 이동**한다.
- 재발 방지: 배포 JS 산출물의 브라우저 하한 초과 문법을 정적 검사가 차단한다. 특히 부팅 임계 번들(코어 엔진 · 템플릿 컴포넌트)은 하한과 같은 버전이라도 정규식 리터럴 전용 문법을 금지한다 — 이 두 파일이 파싱되지 않으면 안내 화면조차 렌더되지 않기 때문이다.
## [engine-v1.60.5] - 2026-08-19
### Fixed
@@ -1469,7 +1469,12 @@ export class DataBindingEngine {
// 2. 기존 로직: 따옴표 없는 $t: 패턴을 문자열로 변환
// $t:key.path → "$t:key.path"
// @since engine-v1.28.1 하이픈(-) 지원 추가
return expr.replace(/(?<!['"]\s*)(\$t:[a-zA-Z_][a-zA-Z0-9_.\-]*)(?!\s*['"])/g, '"$1"');
// @since engine-v1.60.6 lookbehind 제거 — 선행 따옴표를 소비해 명시 분기한다.
// 정규식 lookbehind 는 Safari 16.4 미만이 파싱 자체를 거부해 번들 전체가 실행되지 않는다.
return expr.replace(
/(['"]\s*)?(\$t:[a-zA-Z_][a-zA-Z0-9_.\-]*)(?!\s*['"])/g,
(match, quoted, token) => (quoted ? match : `"${token}"`)
);
}
/**
@@ -1551,13 +1556,16 @@ export class DataBindingEngine {
// 식별자 패턴 (변수명 시작 위치)
// 식별자는 문자, $, _로 시작하고 문자, 숫자, $, _로 계속됨
const identifierPattern = /(?<![.\w$])([a-zA-Z_$][a-zA-Z0-9_$]*)/g;
// @since engine-v1.60.6 lookbehind 제거 — 문두(^) 또는 선행 구분자 1글자를 소비한다.
// 식별자 사이에는 항상 구분자가 1개 이상 있고 각 매치는 정확히 1개만 소비하므로
// 인접 매치가 손실되지 않는다. 식별자명은 match[2] 에 있다(구분자가 match[1]).
const identifierPattern = /(^|[^.\w$])([a-zA-Z_$][a-zA-Z0-9_$]*)/g;
const variables = new Set<string>();
let match;
while ((match = identifierPattern.exec(expr)) !== null) {
const varName = match[1];
const varName = match[2];
// 예약어가 아니고, 이미 추출되지 않았다면 추가
if (!reserved.has(varName)) {
variables.add(varName);
@@ -1293,6 +1293,90 @@ describe('DataBindingEngine', () => {
});
});
/**
* 공개 #121 회귀 — 정규식 lookbehind 제거 (engine-v1.60.6).
*
* lookbehind(`(?<!`)는 ES2018 이지만 WebKit 은 Safari 16.4 에서야 구현했다.
* 정규식 **리터럴**은 파싱 단계에서 검증되므로 그 문법을 모르는 엔진은 번들을
* 한 줄도 실행하지 못한다 — iOS 15 대 전 기기에서 사이트가 통째로 뜨지 않았다.
*
* 교체 시 유일한 위험 지점은 `extractVariablesFromExpression` 의 캡처 인덱스다.
* 선행 구분자를 소비하도록 바꿨으므로 식별자는 match[1] 이 아니라 match[2] 에 있다.
* 이 한 줄을 빠뜨리면 구분자 문자가 변수명으로 수집되어 표현식 평가가 조용히 깨진다.
*
* @effects core_source_has_no_lookbehind, variable_extraction_contract_unchanged
*/
describe('lookbehind 제거 회귀 (engine-v1.60.6, 공개 #121)', () => {
/**
* private 메서드를 테스트에서 직접 호출한다.
* 캡처 인덱스 회귀는 공개 API 결과만으로는 드러나지 않는다.
*
* @param expr 표현식
* @return {string[]} 추출된 변수명
*/
const extract = (expr: string): string[] =>
(engine as any).extractVariablesFromExpression(expr);
it('인접 매치가 손실되지 않는다 (선행 구분자 소비의 부작용 없음)', () => {
expect(extract('a.b.c d.e f')).toEqual(['a', 'd', 'f']);
expect(extract('x+y+z')).toEqual(['x', 'y', 'z']);
expect(extract('p,q,r')).toEqual(['p', 'q', 'r']);
});
it('문두 식별자가 누락되지 않는다 (^ 분기)', () => {
expect(extract('x + y')).toEqual(['x', 'y']);
expect(extract('item')).toEqual(['item']);
});
it('구분자 문자가 변수명에 섞이지 않는다 (match[2] 회귀 가드)', () => {
const vars = extract("a + b, c ? d : e && f || g === 'x'");
for (const v of vars) {
expect(v).toMatch(/^[a-zA-Z_$][a-zA-Z0-9_$]*$/);
}
// 문자열 리터럴 내부는 이 메서드가 걸러내지 않는다(호출부가 이미 토큰화한 뒤 넘긴다).
// 교체 전 lookbehind 판정과 동일한 결과다 — 계약 불변.
expect(vars).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'x']);
});
it('프로퍼티 접근 뒤 식별자는 최상위 변수로 수집하지 않는다', () => {
expect(extract('obj.a.b.c')).toEqual(['obj']);
expect(extract('item?.id')).toEqual(['item']);
expect(extract('.leading')).toEqual([]);
expect(extract('1abc')).toEqual([]);
});
it('공개 API — 인접 식별자를 쓰는 표현식이 정상 평가된다', () => {
const context = { a: 1, b: 2, c: 3 };
expect(engine.evaluateExpression('a+b+c', context)).toBe(6);
expect(engine.evaluateExpression('a + b * c', context)).toBe(7);
});
it('공개 API — 컨텍스트에 없는 변수는 여전히 undefined 로 가려진다', () => {
// 캡처 인덱스가 어긋나면 실제 변수명이 수집되지 않아 ReferenceError 가 된다
expect(() => engine.evaluateExpression('missingVar', {})).not.toThrow();
expect(engine.evaluateExpression('missingVar ?? "fallback"', {})).toBe('fallback');
expect(engine.evaluateExpression('x || y || "none"', {})).toBe('none');
});
it('$t: 처리 계약이 불변이다 (따옴표 유무 분기)', () => {
expect(engine.evaluateExpression('_global.msg || $t:common.error', { _global: {} }))
.toBe('$t:common.error');
expect(engine.evaluateExpression('flag ? $t:a.b : $t:c.d', { flag: false }))
.toBe('$t:c.d');
expect(engine.evaluateExpression('$t:x', {})).toBe('$t:x');
});
it('소스에 lookbehind 가 남아 있지 않다', async () => {
const fs = await import('node:fs');
const path = await import('node:path');
const src = fs.readFileSync(
path.resolve(__dirname, '..', 'DataBindingEngine.ts'),
'utf8'
);
expect(src.match(/\(\?<[!=]/g)).toBeNull();
});
});
describe('{{raw:...}} 바인딩 — 번역 면제 (engine-v1.27.0)', () => {
describe('resolveBindings', () => {
it('단순 경로에 raw 마커 래핑', () => {
@@ -61,11 +61,36 @@
// 헬퍼가 없으면(부분 include 등) 자가 복구 없이 기존 재시도 로직으로만 동작한다.
var assetUrl = window.__g7AssetUrl || null;
// URL 을 문서 기준 절대 URL 로 정규화한다 (ES5 — `new URL` 은 구형 브라우저에 없다).
function absoluteUrl(src) {
var a = document.createElement('a');
a.href = src;
return a.href;
}
// 부트스트랩 상태 — 정적 <script> 의 onerror/onload 가 여기에 기록한다
var bootstrap = window.__g7Bootstrap = {
failed: false,
pending: 0,
/**
* 번들이 파싱 단계에서 거부됐는지 (SyntaxError 관측 결과).
*
* 다운로드는 성공했는데 전역이 없는 상황은 두 가지다 — ① 브라우저가 번들 문법을
* 모른다(지원 범위 밖) ② 번들 자체가 손상됐다. 둘은 안내 문구가 달라야 하므로
* 실제 오류를 관측해 가른다.
*/
syntaxError: false,
/** 부팅에 필요한 번들 URL (절대 URL). 재시도로 바뀐 URL 도 여기에 누적된다. */
bundleSrcs: [absoluteUrl(@json($coreEngineSrc)), absoluteUrl(@json($componentsSrc))],
/** 재시도·모드전환으로 새 URL 을 쓰게 되면 관측 대상에 추가한다. */
trackBundleSrc: function (src) {
var abs = absoluteUrl(src);
if (bootstrap.bundleSrcs.indexOf(abs) === -1) bootstrap.bundleSrcs.push(abs);
},
/** 모드 전환을 이미 시도했는지 (L1 — 페이지 수명당 1회) */
modeSwitched: false,
@@ -86,7 +111,7 @@
if (attempt >= MAX_ATTEMPTS) {
console.error(LABEL + ' Failed to load after ' + MAX_ATTEMPTS + ' attempts: ' + src);
bootstrap.failed = true;
bootstrap.renderFallback();
bootstrap.renderFallback('network');
return;
}
@@ -106,6 +131,8 @@
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);
bootstrap.trackBundleSrc(src);
setTimeout(function () {
var script = document.createElement('script');
script.src = src;
@@ -135,6 +162,8 @@
replaceScript: function (src, attempt) {
if (window.__g7Unloading) return;
bootstrap.trackBundleSrc(src);
var script = document.createElement('script');
script.src = src;
script.async = false; // 삽입 순서대로 실행 (코어 → 컴포넌트 순서 보장)
@@ -154,30 +183,51 @@
*
* 코어 번들이 없을 수 있으므로 템플릿 엔진에 의존하지 않는 순수 DOM + 인라인 스타일.
* 종전에는 콘솔에만 기록되어 사용자에게는 영구 백지로 보였다.
*
* 사유별로 문구가 갈린다 — 새로고침이 도움이 되는 실패와 그렇지 않은 실패를
* 같은 문구로 안내하면 사용자가 자기 회선을 탓하며 영원히 새로고침하게 된다.
*
* @param reason 'network'(다운로드 실패) | 'incompatible'(브라우저가 실행 못 함)
* | 'corrupt'(받았으나 전역 부재, 사유 미상). 미지정 시 'network'.
*/
renderFallback: function () {
renderFallback: function (reason) {
if (window.__g7Unloading) return;
var app = document.getElementById('app');
if (!app) return;
if (app.childElementCount > 0) return; // 이미 렌더된 화면은 덮지 않는다
var incompatible = reason === 'incompatible';
var title = incompatible
? @json(__('errors.bootstrap.incompatible_title'))
: @json(__('errors.bootstrap.title'));
var message = incompatible
? @json(__('errors.bootstrap.incompatible_message'))
: @json(__('errors.bootstrap.message'));
// 새로고침해도 낫지 않는 상황에서 버튼을 두면 그것 자체가 다시 거짓 안내가 된다.
var button = incompatible
? ''
: '<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>';
app.innerHTML =
'<div style="min-height:60vh;display:flex;align-items:center;justify-content:center;padding:24px;' +
'<div data-g7-bootstrap-fallback="' + (incompatible ? 'incompatible' : 'network') + '" ' +
'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;">&#9888;&#65039;</div>' +
'<h1 style="font-size:18px;font-weight:600;margin:0 0 8px;color:#111827;">' +
@json(__('errors.bootstrap.title')) +
title +
'</h1>' +
'<p style="font-size:14px;line-height:1.6;margin:0 0 20px;color:#6b7280;">' +
@json(__('errors.bootstrap.message')) +
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>' +
button +
'</div></div>';
},
@@ -195,12 +245,27 @@
return;
}
// 번들은 받았으나 전역이 없다 = 번들 자체가 손상됨
console.error(LABEL + ' G7Core.initTemplateApp is not available');
bootstrap.renderFallback();
// 번들은 받았으나 전역이 없다 = 실행되지 않았다.
// SyntaxError 를 관측했다면 브라우저가 문법을 모르는 것(지원 범위 밖)이고,
// 그렇지 않으면 사유 미상(번들 손상 등)이므로 기존 문구를 유지한다.
console.error(LABEL + ' G7Core.initTemplateApp is not available'
+ (bootstrap.syntaxError ? ' (bundle rejected at parse time)' : ''));
bootstrap.renderFallback(bootstrap.syntaxError ? 'incompatible' : 'corrupt');
},
};
// 번들의 파싱 실패 관측 (ES5) — 파싱 오류는 <script> 의 error 가 아니라 load 를
// 발생시키므로 element 이벤트로는 구분할 수 없다. window 의 error 이벤트로만 보인다.
window.addEventListener('error', function (e) {
if (!e || !e.filename) return;
if (bootstrap.bundleSrcs.indexOf(absoluteUrl(e.filename)) === -1) return;
var message = e.message ? String(e.message) : '';
if (message.indexOf('SyntaxError') !== -1 || (e.error && e.error.name === 'SyntaxError')) {
bootstrap.syntaxError = true;
}
}, true);
// 정적 <script> 2개(코어 + 컴포넌트)가 완료돼야 초기화한다
bootstrap.pending = 2;
})();
@@ -0,0 +1,154 @@
<?php
namespace Tests\Feature\View;
use Tests\TestCase;
/**
* 부팅 실패 사유별 안내 분기 (공개 #121).
*
* 번들을 받았는데 실행되지 않는 경우와 끝내 받지 못한 경우는 사용자가 취할 행동이
* 정반대다. 전자에 "네트워크가 불안정하다 / 새로고침하라" 를 띄우면 새로고침해도
* 낫지 않으므로 사용자는 자기 회선을 탓하게 된다.
*
* 인라인 부트스트랩은 코어 번들이 없어도 동작해야 하므로 ES5 로만 작성한다 —
* 화살표 함수·const·템플릿 리터럴·옵셔널 체이닝이 섞이면 이 계층까지 구형
* 브라우저에서 죽어 안내조차 뜨지 않는다.
*
* @scenario entrypoint=user, failure_mode=parse_error
*
* @effects bootstrap_partial_emits_reason_branch, incompatible_strings_defined_per_locale
*/
class BootstrapFallbackDiagnosisTest extends TestCase
{
/**
* 부트스트랩 partial 을 렌더합니다.
*
* @param string $templateType 'user' 또는 'admin'
* @return string 렌더된 HTML
*/
private function render(string $templateType = 'user'): string
{
return view('partials.bootstrap-scripts', [
'templateType' => $templateType,
'coreEngineSrc' => '/build/core/template-engine.min.js',
'componentsSrc' => '/api/templates/assets/sirsoft-basic/js/components.iife.js',
'initConfig' => ['templateId' => 'sirsoft-basic', 'locale' => 'ko'],
])->render();
}
public function test_두_진입점_모두_사유별_분기를_렌더한다(): void
{
foreach (['user', 'admin'] as $templateType) {
$html = $this->render($templateType);
$this->assertStringContainsString(
"? 'incompatible' : 'corrupt'",
$html,
"[{$templateType}] 전역 부재 경로가 SyntaxError 관측 결과로 사유를 가르지 않는다"
);
$this->assertStringContainsString(
"renderFallback('network')",
$html,
"[{$templateType}] 재시도 소진 경로가 network 사유를 넘기지 않는다"
);
$this->assertStringContainsString(
'data-g7-bootstrap-fallback',
$html,
"[{$templateType}] 폴백 사유 마커가 없다"
);
}
}
public function test_파싱_오류_관측_리스너가_설치된다(): void
{
$html = $this->render();
// 파싱 실패는 <script> 의 error 가 아니라 load 를 발생시키므로 element
// 이벤트로는 구분할 수 없다. window 의 error 이벤트로만 관측된다.
$this->assertStringContainsString('bundleSrcs', $html);
$this->assertStringContainsString('SyntaxError', $html);
$this->assertStringContainsString('syntaxError', $html);
}
public function test_비호환_안내_문구가_지원_로케일마다_정의된다(): void
{
$original = app()->getLocale();
try {
foreach (['ko', 'en'] as $locale) {
app()->setLocale($locale);
foreach (['incompatible_title', 'incompatible_message'] as $key) {
$value = __("errors.bootstrap.{$key}");
$this->assertNotSame(
"errors.bootstrap.{$key}",
$value,
"[{$locale}] errors.bootstrap.{$key} 미정의 — 화면에 키 문자열이 그대로 노출된다"
);
$this->assertNotSame('', trim($value), "[{$locale}] errors.bootstrap.{$key} 가 비어 있다");
}
// 사유가 다르면 문구도 달라야 한다 — 같으면 분기가 무의미하다
$this->assertNotSame(
__('errors.bootstrap.message'),
__('errors.bootstrap.incompatible_message'),
"[{$locale}] 비호환 문구가 네트워크 문구와 동일하다"
);
}
} finally {
app()->setLocale($original);
}
}
public function test_번들_일본어_언어팩에도_비호환_문구가_있다(): void
{
$path = base_path('lang-packs/_bundled/g7-core-ja/backend/ja/errors.php');
if (! file_exists($path)) {
$this->markTestSkipped('번들 ja 언어팩 미존재');
}
$ja = require $path;
$this->assertArrayHasKey('bootstrap', $ja);
$this->assertArrayHasKey('incompatible_title', $ja['bootstrap']);
$this->assertArrayHasKey('incompatible_message', $ja['bootstrap']);
$this->assertNotSame(
$ja['bootstrap']['message'],
$ja['bootstrap']['incompatible_message'],
'ja 비호환 문구가 네트워크 문구와 동일하다'
);
}
public function test_인라인_부트스트랩은_구형_브라우저_문법만_사용한다(): void
{
$html = $this->render();
preg_match('/<script>(.*?)<\/script>/s', $html, $m);
$this->assertNotEmpty($m, '인라인 부트스트랩 스크립트를 찾지 못했다');
// 주석 안의 백틱·화살표는 문법이 아니다 — 블록 주석과 줄 주석을 먼저 걷어낸다.
// 콜론 뒤의 `//` 는 URL 스킴이므로 주석으로 보지 않는다.
$js = preg_replace('#/\*.*?\*/#s', '', $m[1]);
$js = preg_replace('#(^|[^:])//[^\n]*#', '$1', $js);
// 코어 번들이 없어도 이 계층은 살아 있어야 한다 — ES2015+ 문법 금지.
$forbidden = [
'화살표 함수' => '/\)\s*=>/',
'const 선언' => '/(^|[^\w$])const\s+[A-Za-z_$]/m',
'let 선언' => '/(^|[^\w$])let\s+[A-Za-z_$]/m',
'템플릿 리터럴' => '/`/',
'옵셔널 체이닝' => '/\?\./',
];
foreach ($forbidden as $label => $pattern) {
$this->assertSame(
0,
preg_match($pattern, $js),
"인라인 부트스트랩에 {$label} 사용 — 구형 브라우저에서 안내 화면조차 뜨지 않는다"
);
}
}
}
@@ -0,0 +1,220 @@
/**
* Smoke: 부팅 실패 사유별 안내 분기 (공개 #121)
*
* 번들을 **받았는데 실행되지 않는** 경우와 **끝내 받지 못한** 경우는 사용자가 취해야 할
* 행동이 정반대다. 전자는 새로고침해도 영원히 낫지 않으므로 "네트워크가 불안정하다 /
* 새로고침하라" 는 거짓 진단이고, 사용자는 자기 회선을 탓하게 된다.
*
* 기존 `network-resilience.spec.ts` 는 `route.abort()` 로 **네트워크 실패만** 모사한다.
* "다운로드는 되지만 파싱에 실패" 경로를 덮는 spec 이 없었던 것이 이번 결함(iOS 15 전
* 기기 부팅 불능)이 통과한 이유다.
*
* 파싱 실패 모사: 응답을 가로채 **파싱 불가한 정규식 리터럴**을 본문으로 돌려준다.
* 구형 Safari 가 lookbehind 를 거부하던 것과 같은 종류의 실패다 — 스크립트 태그는
* `error` 가 아니라 `load` 를 발생시키므로 재시도조차 일어나지 않고 곧장 tryInit 으로 간다.
*
* 진입점 두 곳(사용자·관리자)을 모두 잰다. 한쪽만 보면 blade 계층이 갈린 지점의
* 회귀를 놓친다.
*/
import { test, expect, type Page } from '@playwright/test';
/** 코어 엔진 번들 URL 패턴 */
const CORE_BUNDLE = '**/build/core/template-engine.min.js*';
/**
* 파싱 불가한 스크립트 본문.
*
* `(?<@x)` 는 유효하지 않은 캡처 그룹 이름이라 정규식 **리터럴** 검증 단계에서 거부된다
* (구형 WebKit 이 lookbehind 를 거부하던 지점과 동일). 파일 전체가 실행되지 않으므로
* 앞줄의 대입조차 수행되지 않고 `window.G7Core` 는 정의되지 않는다.
*/
const UNPARSEABLE_JS = 'window.__g7ParseProbe = 1;\nvar broken = /(?<@x)y/;\n';
/**
* 폴백 안내가 그려질 때까지 기다린 뒤 그 상태를 읽는다.
*
* 부재 단언은 **존재를 확정한 뒤**에만 의미가 있다 — 폴백 컨테이너가 뜨기 전에
* 버튼 수를 세면 렌더 전이라 0 이 나와 그냥 통과한다.
*
* @param page Playwright page
* @return 폴백 마커·문구·버튼 수
*/
async function readFallback(page: Page): Promise<{ reason: string | null; title: string; buttons: number }> {
const container = page.locator('#app [data-g7-bootstrap-fallback]');
await expect(container).toBeVisible({ timeout: 20_000 });
return page.evaluate(() => {
const el = document.querySelector('#app [data-g7-bootstrap-fallback]');
return {
reason: el ? el.getAttribute('data-g7-bootstrap-fallback') : null,
title: el?.querySelector('h1')?.textContent?.trim() ?? '',
buttons: el ? el.querySelectorAll('button').length : 0,
};
});
}
/**
* 파싱 실패 케이스 본문 — 진입점만 다르다.
*
* @param page Playwright page
* @param url 진입 경로
* @return {Promise<void>}
*/
async function assertParseFailure(page: Page, url: string): Promise<void> {
const pageErrors: string[] = [];
page.on('pageerror', (e) => pageErrors.push(String(e)));
await page.route(CORE_BUNDLE, (route) =>
route.fulfill({ status: 200, contentType: 'application/javascript', body: UNPARSEABLE_JS })
);
await page.goto(url);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const fallback = await readFallback(page);
expect(fallback.reason).toBe('incompatible');
expect(fallback.title.length).toBeGreaterThan(0);
// 존재를 확정한 뒤의 부재 단언 — 새로고침 버튼이 없어야 한다
expect(fallback.buttons).toBe(0);
const state = await page.evaluate(() => ({
hasCore: typeof (window as any).G7Core !== 'undefined',
syntaxError: (window as any).__g7Bootstrap?.syntaxError,
}));
expect(state.hasCore).toBe(false);
expect(state.syntaxError).toBe(true);
expect(pageErrors.join(' | ')).toMatch(/SyntaxError/);
}
/**
* 네트워크 실패 케이스 본문 — 기존 문구·버튼·재시도 상한이 그대로여야 한다.
*
* @param page Playwright page
* @param url 진입 경로
* @return {Promise<void>}
*/
async function assertNetworkFailure(page: Page, url: string): Promise<void> {
let attempts = 0;
await page.route(CORE_BUNDLE, (route) => {
attempts += 1;
return route.abort('failed');
});
await page.goto(url);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const fallback = await readFallback(page);
expect(fallback.reason).toBe('network');
expect(fallback.buttons).toBe(1);
// 재시도 상한(초기 1 + 재시도 2) 불변
expect(attempts).toBe(3);
}
/**
* 정상 부팅 케이스 본문 — 폴백이 오발동하지 않아야 한다.
*
* @param page Playwright page
* @param url 진입 경로
* @return {Promise<void>}
*/
async function assertNormalBoot(page: Page, url: string): Promise<void> {
await page.goto(url);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.waitForFunction(
() => (document.querySelector('#app')?.childElementCount ?? 0) > 0,
{ timeout: 20_000 }
);
const state = await page.evaluate(() => ({
failed: (window as any).__g7Bootstrap?.failed,
syntaxError: (window as any).__g7Bootstrap?.syntaxError,
hasCore: typeof (window as any).G7Core !== 'undefined',
fallbacks: document.querySelectorAll('#app [data-g7-bootstrap-fallback]').length,
}));
expect(state.hasCore).toBe(true);
expect(state.failed).toBe(false);
expect(state.syntaxError).toBe(false);
expect(state.fallbacks).toBe(0);
}
test.describe('부팅 실패 사유별 안내 분기 (공개 #121)', () => {
/**
* @scenario entrypoint=user, failure_mode=parse_error
* @effects syntax_error_renders_incompatible_notice, incompatible_notice_omits_reload_button
*/
test('@smoke [user] 번들 파싱 실패 → 브라우저 비호환 안내 + 새로고침 버튼 부재', async ({ page }) => {
await assertParseFailure(page, '/');
});
/**
* @scenario entrypoint=admin, failure_mode=parse_error
* @effects syntax_error_renders_incompatible_notice, incompatible_notice_omits_reload_button
*/
test('@smoke [admin] 번들 파싱 실패 → 브라우저 비호환 안내 + 새로고침 버튼 부재', async ({ page }) => {
await assertParseFailure(page, '/admin');
});
/**
* @scenario entrypoint=user, failure_mode=network_exhausted
* @effects network_failure_renders_network_notice, network_notice_keeps_reload_button
*/
test('@smoke [user] 번들 상시 부재 → 기존 네트워크 안내 + 새로고침 버튼 유지', async ({ page }) => {
await assertNetworkFailure(page, '/');
});
/**
* @scenario entrypoint=admin, failure_mode=network_exhausted
* @effects network_failure_renders_network_notice, network_notice_keeps_reload_button
*/
test('@smoke [admin] 번들 상시 부재 → 기존 네트워크 안내 + 새로고침 버튼 유지', async ({ page }) => {
await assertNetworkFailure(page, '/admin');
});
/**
* @scenario entrypoint=user, failure_mode=none
* @effects normal_boot_renders_no_fallback
*/
test('@smoke [user] 정상 부팅 → 폴백 미렌더', async ({ page }) => {
await assertNormalBoot(page, '/');
});
/**
* @scenario entrypoint=admin, failure_mode=none
* @effects normal_boot_renders_no_fallback
*/
test('@smoke [admin] 정상 부팅 → 폴백 미렌더', async ({ page }) => {
await assertNormalBoot(page, '/admin');
});
/**
* 서빙되는 코어 엔진 번들에 lookbehind 가 없다.
*
* 저장소 산출물은 정적 검사가 덮지만, **실제로 서빙되는 것**이 그 산출물인지는
* 별개 축이다(배포 경로·캐시·리버스 프록시). 브라우저가 받는 바이트를 직접 센다.
*
* @effects shipped_core_bundle_has_no_lookbehind
*/
test('@smoke 서빙 중인 코어 엔진 번들에 정규식 lookbehind 가 없다', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const probe = await page.evaluate(async () => {
const el = Array.from(document.querySelectorAll('script[src]')).find((s) =>
/template-engine\.min\.js/.test((s as HTMLScriptElement).src)
) as HTMLScriptElement | undefined;
if (!el) return { found: false, bytes: 0, lookbehind: -1 };
const body = await fetch(el.src).then((r) => r.text());
return { found: true, bytes: body.length, lookbehind: (body.match(/\(\?<[!=]/g) || []).length };
});
expect(probe.found).toBe(true);
expect(probe.bytes).toBeGreaterThan(1000);
expect(probe.lookbehind).toBe(0);
});
});
@@ -24,16 +24,39 @@ const DEAD_SCREEN = /초기화 실패|페이지 로딩 실패|Unknown action han
/** 코어/컴포넌트 번들이 끝내 부재할 때 사용자에게 보여야 할 폴백 안내 */
const FALLBACK_UI = /화면을 불러오지 못했습니다|Failed to load the page/;
/**
* 자산 URL 이 **이중 모드**라는 사실을 흡수하는 매처 생성기.
*
* `asset_url_mode` 가 `extensionless` 인 사이트에서는 `.../routes.json` 이
* `.../routes` 로, `/api/modules/bundle.js` 가 `/api/modules/bundle/js` 로 나간다
* (nginx 정적 최적화 블록 우회 — `partials/asset-url-recovery` 참조).
* 확장자 형태만 매칭하면 그 사이트에서는 **한 건도 가로채지 못한 채** 테스트가
* "복구 성공" 처럼 조용히 통과하거나 카운터 0 으로 실패한다.
*
* @param base 확장자 앞까지의 경로 정규식 소스 (예: `\\/api\\/templates\\/[^/]+\\/routes`)
* @param ext 확장자 (예: 'json')
* @return URL 매처
*/
function suffixedPath(base: string, ext: string): (url: URL) => boolean {
const withExt = new RegExp(`^${base}\\.${ext}$`);
const without = new RegExp(`^${base}$`);
const segment = new RegExp(`^${base}\\/${ext}$`);
return (url: URL) => withExt.test(url.pathname) || without.test(url.pathname) || segment.test(url.pathname);
}
/**
* 지정 패턴의 **첫 요청 1건만** 취소한다 (재시도 요청은 통과).
*
* 일시적 커넥션 유실을 모사한다 — 재시도가 실제로 복구하는지 보기 위함.
*
* @param page Playwright page
* @param pattern 취소할 요청 URL glob
* @param pattern 취소할 요청 URL glob 또는 매처
* @return 취소·통과 카운터 (재시도 발동 여부 확인용)
*/
async function abortFirstRequestOnly(page: Page, pattern: string): Promise<{ hits: () => number }> {
async function abortFirstRequestOnly(
page: Page,
pattern: string | ((url: URL) => boolean)
): Promise<{ hits: () => number }> {
let hits = 0;
await page.route(pattern, (route) => {
hits += 1;
@@ -50,10 +73,10 @@ async function bodyText(page: Page): Promise<string> {
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*' },
const SINGLE_ABORT_PATHS: Array<{ name: string; pattern: string | ((url: URL) => boolean) }> = [
{ name: 'routes.json', pattern: suffixedPath('\\/api\\/templates\\/[^/]+\\/routes', 'json') },
{ name: 'components.json', pattern: suffixedPath('\\/api\\/templates\\/[^/]+\\/components', 'json') },
{ name: 'modules/bundle.js', pattern: suffixedPath('\\/api\\/modules\\/bundle', 'js') },
{ name: 'layouts/*.json', pattern: '**/api/layouts/**' },
];
@@ -221,7 +244,7 @@ test.describe('네트워크 복원력 — 번들이 끝내 부재할 때 (#463)'
*/
test('@smoke routes.json 상시 부재 → 명시적 에러 화면 + 새로고침 버튼', async ({ page }) => {
let attempts = 0;
await page.route('**/api/templates/*/routes.json*', (route) => {
await page.route(suffixedPath('\\/api\\/templates\\/[^/]+\\/routes', 'json'), (route) => {
attempts += 1;
return route.abort('failed');
});
@@ -251,7 +274,7 @@ test.describe('네트워크 복원력 — 번들이 끝내 부재할 때 (#463)'
* (b) `Unknown action handler: sirsoft-ecommerce.initPreferredCurrency` raw 노출
*/
test('@smoke 모듈 번들 상시 부재 → 5초 블로킹 없이 렌더 + 내부 식별자 미노출', async ({ page }) => {
await page.route('**/api/modules/bundle.js*', (route) => route.abort('failed'));
await page.route(suffixedPath('\\/api\\/modules\\/bundle', 'js'), (route) => route.abort('failed'));
// `waitUntil: 'commit'` — 기본값('load')은 취소된 번들의 재시도 체인까지 포함한
// 모든 서브리소스를 기다리므로, 측정 시작점이 밀려 "렌더까지 걸린 시간" 이 아니라
@@ -0,0 +1,48 @@
feature: bootstrap_parse_failure
description: |
부팅 스크립트 실패 사유별 안내 분기 (공개 #121).
번들을 **받았는데 실행되지 않는** 경우와 **끝내 받지 못한** 경우는 사용자가 취해야 할
행동이 정반대다. 파싱 실패 시 스크립트 태그는 error 가 아니라 load 를 발생시키므로
재시도조차 일어나지 않고 곧장 "전역 부재" 경로로 간다 — 종전에는 그 경로가 네트워크
문구를 그대로 그려, 새로고침해도 낫지 않는 상황에서 새로고침을 권했다.
핵심 동작:
- window 의 error 이벤트로 번들 SyntaxError 를 관측(element 이벤트로는 구분 불가)
- 관측되면 renderFallback('incompatible') — 전용 문구 + 새로고침 버튼 생략
- 관측되지 않으면 renderFallback('corrupt') — 기존 문구 유지(사유 미상)
- 재시도 소진 경로는 renderFallback('network') — 기존 문구 + 버튼 유지
- 코어 엔진 번들에서 정규식 lookbehind 제거(파싱 하한 Safari 16.4 → 14.1)
axes:
entrypoint: [user, admin]
failure_mode: [parse_error, network_exhausted, none]
effects:
- syntax_error_renders_incompatible_notice
- incompatible_notice_omits_reload_button
- network_failure_renders_network_notice
- network_notice_keeps_reload_button
- normal_boot_renders_no_fallback
- shipped_core_bundle_has_no_lookbehind
- bootstrap_partial_emits_reason_branch
- incompatible_strings_defined_per_locale
- core_source_has_no_lookbehind
- variable_extraction_contract_unchanged
- shipped_bundle_lookbehind_blocked_by_static_check
test_files:
- tests/Playwright/specs/smoke/bootstrap-parse-failure.spec.ts
- tests/Feature/View/BootstrapFallbackDiagnosisTest.php
- resources/js/core/template-engine/__tests__/DataBindingEngine.test.ts
- .claude/scripts/audit/__tests__/shipped-bundle-browser-baseline.test.cjs
notes: |
로케일 축은 E2E cross product 에 넣지 않는다 — 화면 문구는 서버가 렌더한 문자열을
그대로 심는 구조라 브라우저 경로가 로케일마다 갈리지 않기 때문이다. ko/en/ja 문구의
실존과 상호 구분은 BootstrapFallbackDiagnosisTest 가 직접 단언하고, 번들 ja 언어팩의
키 동기는 언어팩 동기 검사가 담당한다.
하한 미만 실기기(iOS 15) 에서 "깨진 채로 이용 가능한가" 는 재현 수단이 없어 이 매니페스트
범위 밖이다 — 최신 WebKit(Playwright) 도 lookbehind 를 지원해 결함 자체가 재현되지 않는다.