공개 제보 https://github.com/gnuboard/g7/issues/122 대응 — 초기 부트스트랩 리소스(다국어 병합·컴포넌트 정의·라우트·확장 번들·템플릿 dist)를 캐시 버전 디렉토리(public/build/ext/{v}/)에 실파일로 게시해 웹서버가 rewrite 전에 직접 서빙한다. 부트 임계 경로의 PHP 왕복을 제거하고(실측 TTFB 131~144ms → 1~7ms), 미게시·부분게시·GC 직후에는 fetch·태그·번들 3계층이 종전 API 로 즉시 폴백한다. - 게시: 원자적 tmp→rename→manifest(존재=완료), 캐시 락 단일 실행, 인라인 GC (현재+직전 1개), incrementExtensionCacheVersion 단일 지점 terminating 트리거 + blade 자가 치유 + 설치기 태스크(best_effort) + 일일 cleanup 스케줄, sudo 업데이트 대비 소유권 정상화(normalizeOwnership)·prune/백업 제외 - 프론트(engine-v1.61.0): blade 주입 cache_version 1급 시드(이중 부트 로드 제거), fetchStaticFirst 즉시 폴백, ComponentRegistry 버전 키드 매니페스트, ModuleAssetLoader 번들 정적→레거시 폴백, asset-url-recovery staticToLegacy 역변환 - 폴백 API 품질: lang/components/routes ETag+304 + 환경 분기 Cache-Control, 열화 라우트 스냅샷 공개 캐시 금지(서버측 캐시 회피와 대칭), 게시 .htaccess mod_deflate + nginx gzip 스니펫(압축 전송량 회귀 방지) - SEO 정합: 봇 HTML 은 GC 대상 정적 URL 미사용(allowStatic:false), props $switch 봇측 해석 구현(engine-v1.56.0 패리티), 패리티 룰 expression-dialect 그룹 신설, 상주 allow 헤더 제거로 잠금 복원, _comment* 접두 주석 키 분류 - 검증: 전 수정 red→green 4단계, Playwright 라이브 21건, Chrome MCP 24축+M1~M3, 봇 curl 3축, 캐시 저장소(file/redis/database) 축 판정, 히스토리·공개이슈·커밋 이력 전수 재조사 반영 - 코어 7.0.10, engine-v1.61.0. kill-switch: G7_STATIC_CACHE=false
113 lines
4.7 KiB
TypeScript
113 lines
4.7 KiB
TypeScript
/**
|
|
* Smoke: 정적 게시(bake) fast path + API 폴백 (#122 S2·S3)
|
|
*
|
|
* 정적 게시가 켜진 프로덕션 사이트에서 ① 부트 리소스가 `/build/ext/{v}/…` 로
|
|
* 수신되고 ② 정적 응답을 브라우저단에서 404 로 강제해도 legacy API 폴백으로
|
|
* 화면이 정상 렌더됨을 잠근다. 서버 파일 조작이 없으므로 원복이 불필요하다.
|
|
*
|
|
* env 가드 — 대상 사이트가 정적 게시 미적용(비프로덕션/kill-switch/미게시)이면
|
|
* staticBase 미주입으로 skip 된다 (첫 방문이 자가 치유를 예약하므로 워밍 1회 수행).
|
|
*
|
|
* @scenario publish_state=partial, environment=production, trigger=self_heal
|
|
* @effects static_first_fetch_falls_back_to_api_on_miss, fallback_is_observable_via_console_warn
|
|
*/
|
|
import { test, expect, type Page } from '@playwright/test';
|
|
|
|
/** 대상 사이트의 staticBase 주입 여부 (자가 치유 워밍 포함) */
|
|
async function probeStaticBase(page: Page): Promise<string | null> {
|
|
await page.goto('/');
|
|
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
|
|
|
let base = await page.evaluate(() => (window as any).G7Config?.staticBase ?? null);
|
|
|
|
if (!base) {
|
|
// 미게시 상태였다면 방금 렌더가 terminating 게시를 예약했다 — 1회 재방문
|
|
await page.waitForTimeout(3_000);
|
|
await page.reload();
|
|
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
|
base = await page.evaluate(() => (window as any).G7Config?.staticBase ?? null);
|
|
}
|
|
|
|
return typeof base === 'string' ? base : null;
|
|
}
|
|
|
|
test.describe('정적 게시 fast path + 폴백 (#122)', () => {
|
|
/**
|
|
* @scenario publish_state=published, environment=production, trigger=self_heal
|
|
* @effects versioned_boot_urls
|
|
*/
|
|
test('@smoke 정적 URL 로 부트 리소스를 수신한다 (static-first)', async ({ page }) => {
|
|
const staticUrls: string[] = [];
|
|
page.on('request', (request) => {
|
|
if (new URL(request.url()).pathname.startsWith('/build/ext/')) {
|
|
staticUrls.push(request.url());
|
|
}
|
|
});
|
|
|
|
const base = await probeStaticBase(page);
|
|
test.skip(base === null, '대상 사이트에 정적 게시 미적용 (staticBase 미주입)');
|
|
|
|
await page.reload();
|
|
await page.waitForFunction(
|
|
() => (document.querySelector('#app')?.childElementCount ?? 0) > 0,
|
|
{ timeout: 30_000 }
|
|
);
|
|
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
|
|
|
// 정적 게시 경로에서 부트 리소스를 실제로 받는다
|
|
expect(staticUrls.length, `정적 경로 수신: ${staticUrls.join(', ')}`).toBeGreaterThan(0);
|
|
expect(staticUrls.some((u) => /\/templates\/[^/]+\/routes\.json/.test(u))).toBe(true);
|
|
});
|
|
|
|
test('@smoke 정적 응답을 404 로 강제해도 legacy API 폴백으로 화면이 정상 렌더된다', async ({ page }) => {
|
|
const base = await probeStaticBase(page);
|
|
test.skip(base === null, '대상 사이트에 정적 게시 미적용 (staticBase 미주입)');
|
|
|
|
const warns: string[] = [];
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'warning') warns.push(message.text());
|
|
});
|
|
|
|
let aborted = 0;
|
|
const apiFallbacks: string[] = [];
|
|
|
|
// 정적 fetch 리소스(routes/lang/components)만 404 강제 — 태그 자산(CSS/JS)은
|
|
// 파일 게이트가 이미 통과시킨 실파일이므로 건드리지 않는다 (렌더 자체를 위한 것)
|
|
await page.route(
|
|
(url) => /\/build\/ext\/\d+\/templates\/[^/]+\/(routes|components|lang\/[a-z-]+)\.json$/.test(url.pathname),
|
|
(route) => {
|
|
aborted += 1;
|
|
return route.fulfill({ status: 404, body: 'forced miss' });
|
|
}
|
|
);
|
|
|
|
page.on('request', (request) => {
|
|
const path = new URL(request.url()).pathname;
|
|
if (/^\/api\/templates\/[^/]+\/(routes|components|lang\/[a-z-]+)(\.json)?$/.test(path)) {
|
|
apiFallbacks.push(request.url());
|
|
}
|
|
});
|
|
|
|
await page.reload();
|
|
await page.waitForFunction(
|
|
() => (document.querySelector('#app')?.childElementCount ?? 0) > 0,
|
|
{ timeout: 30_000 }
|
|
);
|
|
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
|
|
|
// 정적 미스가 실제로 발생했고
|
|
expect(aborted).toBeGreaterThan(0);
|
|
|
|
// legacy API 폴백이 그 자리를 메웠으며
|
|
expect(apiFallbacks.length, `API 폴백: ${apiFallbacks.join(', ')}`).toBeGreaterThan(0);
|
|
|
|
// 화면은 전면 에러 없이 정상이다
|
|
const text = await page.evaluate(() => document.body.innerText.trim());
|
|
expect(text).not.toMatch(/초기화 실패|페이지 로딩 실패/);
|
|
expect(text.length).toBeGreaterThan(20);
|
|
|
|
// 폴백은 조용하지 않다 — console warn 으로 관측 가능
|
|
expect(warns.some((w) => w.includes('fetchStaticFirst')), `warns: ${warns.join(' | ')}`).toBe(true);
|
|
});
|
|
});
|