Files
Gnuboard7/tests/Playwright/specs/smoke/static-cache-fallback.spec.ts
T
HeuJung b4ed33aa44 feat(core): 정적 게시 수명주기 보완 — 빌드 파괴·권한·무결성
https://github.com/gnuboard/g7/issues/122 제보자의 추가 지적 2건(빌드가 게시본을
지움 / CLI 최초 게시 후 웹 재생성 불가)에 대응하고, 같은 근본 원인을 공유하는
결함을 전수조사로 함께 고친다.

- 빌드가 자기 산출물만 교체한다: 루트 vite `emptyOutDir: false`
 기본값 true 는 폴백 없는 코어 3번들과 배달된 immutable URL 의 게시본을 함께 지웠다
- 게시 트리 권한을 웹이 이어받는다: 병합 전 프리플라이트 + 부모 그룹 상속·g+w
 종전 소유권 정상화는 root 축만 덮어 비-root CLI 계정은 무방비였다
- 갱신 → 생성 → 완전성 확인을 한 묶음으로: 기록 바이트 대조, .old 원자 스왑,
 rename 일시 거부 재시도, 프론트 JSON 파싱 검증
- 실패를 억제하고 기록한다: 버전 한정 실패 마커 + 사유별 대시보드 알림
 + ext-static:status 점검 커맨드
- 파생: catch-all 제외 목록을 에셋 화이트리스트 합집합에서 파생(mjs·webp·otf 누락),
 번들 디스크 쓰기 fail-soft·빈 번들 503, 활성 디렉토리 prune 회피
2026-08-28 08:02:22 +09:00

205 lines
9.0 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, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=self_heal, process_user=web
* @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, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=self_heal, process_user=web
* @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);
});
/**
* 정적 응답이 **200 인데 본문이 손상**된 경우에도 폴백해 화면이 정상이어야 한다.
*
* 디스크 풀/quota 로 절단된 게시본은 웹서버가 정상 200 으로 서빙한다. `response.ok`
* 만 보던 종전 구현은 폴백하지 못한 채 `response.json()` 이 던져 부팅 전체가 실패했다 —
* 3층 폴백이 유일하게 개입하지 못하던 경로다.
*
* @scenario publish_state=partial, artifact_integrity=absent, filesystem_writable=writable, environment=production, trigger=self_heal, process_user=web
* @effects truncated_artifact_rejected_before_manifest, static_first_fetch_falls_back_to_api_on_miss
*/
test('@smoke 정적 200 + 손상 본문이어도 폴백으로 화면이 정상 렌더된다', 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 corrupted = 0;
const apiFallbacks: string[] = [];
// 404 가 아니라 **200 + 잘린 JSON** 을 돌려준다 — 종전 구현이 통과시키던 형태.
await page.route(
(url) => /\/build\/ext\/\d+\/templates\/[^/]+\/(routes|components|lang\/[a-z-]+)\.json$/.test(url.pathname),
(route) => {
corrupted += 1;
return route.fulfill({
status: 200,
contentType: 'application/json',
body: '{"success":true,"data":{"messa',
});
}
);
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(corrupted).toBeGreaterThan(0);
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);
expect(
warns.some((w) => w.includes('malformed JSON')),
`warns: ${warns.join(' | ')}`
).toBe(true);
});
/**
* 정적 미스는 SPA 셸 HTML 이 아니라 404 를 받아야 한다 — 확장자 변종 포함.
*
* `mjs` · `webp` · `otf` 는 에셋 서빙이 허용하는데 catch-all 제외 목록에는 없었다.
* 없는 `.mjs` 가 `Content-Type: text/html` 인 200 을 받으면 브라우저는 그것을
* 스크립트로 파싱하다 죽는데, 응답이 성공이라 `onerror` 도 발화하지 않는다.
*
* @effects static_miss_returns_404_for_every_servable_extension
*/
test('@smoke 없는 정적 자산은 확장자 변종에서도 404 다 (HTML 200 아님)', async ({ page }) => {
const base = await probeStaticBase(page);
test.skip(base === null, '대상 사이트에 정적 게시 미적용 (staticBase 미주입)');
for (const extension of ['mjs', 'webp', 'otf', 'js', 'css', 'json']) {
const response = await page.request.get(
`${base}/templates/sirsoft-basic/assets/__missing__.${extension}`
);
const contentType = response.headers()['content-type'] ?? '';
// 계약은 **상태 코드**다. 404 응답의 본문이 HTML 인 것은 정상이며(라라벨 오류 페이지),
// 문제는 "없는 자산에 200 이 나가는 것" 이었다 — 200 이면 브라우저가 그 HTML 을
// 스크립트로 파싱하다 죽고, 응답이 성공이라 onerror 도 발화하지 않는다.
expect(
response.status(),
`.${extension} 가 ${response.status()} (content-type: ${contentType}) — SPA 셸 200 이 반환되면 스크립트 파싱이 죽는다`
).toBe(404);
}
});
});