코어 엔진 번들의 정규식 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 이 한 건도 매칭되지 않아 실패하고 있었다. 같은 파일의 컴포넌트 번들 테스트만 이중 모드를 알고 형제 케이스는 몰랐던 불균형이라 매처를 통일했다.
305 lines
14 KiB
TypeScript
305 lines
14 KiB
TypeScript
/**
|
|
* 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/;
|
|
|
|
/**
|
|
* 자산 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 또는 매처
|
|
* @return 취소·통과 카운터 (재시도 발동 여부 확인용)
|
|
*/
|
|
async function abortFirstRequestOnly(
|
|
page: Page,
|
|
pattern: string | ((url: URL) => boolean)
|
|
): 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 | ((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/**' },
|
|
];
|
|
|
|
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건 = 정확히 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 }) => {
|
|
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()).toBe(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);
|
|
});
|
|
|
|
/**
|
|
* 템플릿 컴포넌트 번들도 코어 번들과 동일한 `<script src>` 사망 경로다.
|
|
* 이쪽이 끝내 부재하면 코어 엔진(`G7Core`)은 살아 있지만 렌더할 컴포넌트가 없어
|
|
* 화면을 만들 수 없다 — 백지로 끝나면 안 되고 폴백 안내로 끝나야 한다.
|
|
*
|
|
* (1건 취소 → 복구 는 검증하지 않는다. 위 'script 번들 경로' 주석대로 브라우저가
|
|
* abort 된 script 를 스스로 재요청해 수정 전에도 통과하기 때문. 재시도가 실제로
|
|
* 값을 갖는 지점은 브라우저가 포기한 뒤인 여기다.)
|
|
*/
|
|
test('@smoke 템플릿 컴포넌트 번들 상시 부재 → 백지가 아니라 폴백 안내 + 새로고침 버튼', async ({ page }) => {
|
|
let attempts = 0;
|
|
// 자산 URL 은 **이중 모드**다 — 확장자 형태(`.../js/components.iife.js`)가 실패하면
|
|
// 로더가 확장자 없는 형태(`...?file=js%2Fcomponents.iife.js`)로 재시도한다. 확장자 형태만
|
|
// 막으면 두 번째 형태로 번들이 정상 로드돼(실측 확인) "상시 부재" 를 재현하지 못한다.
|
|
await page.route(
|
|
(url) =>
|
|
/\/api\/templates\/assets\//.test(url.pathname) &&
|
|
(/components\.iife\.js/.test(url.pathname) ||
|
|
/components\.iife\.js/.test(decodeURIComponent(url.search))),
|
|
(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(suffixedPath('\\/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(suffixedPath('\\/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);
|
|
});
|
|
});
|