Merge pull request from gnuboard:HeuJung/issue604

fix(engine): 구형 Safari 부팅 실패 해소 및 배포 번들 브라우저 하한 회귀 방지
This commit is contained in:
정정홍
2026-08-21 21:07:33 +09:00
committed by GitHub
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 를 지원해 결함 자체가 재현되지 않는다.