Files
Gnuboard7/tests/Playwright/specs/smoke/static-cache-fallback.spec.ts
T
HeuJung 0bb33e3baa fix(core): root 프로세스의 terminating 정적 게시를 웹 자가 치유로 위임
sudo 코어 업데이트(7.0.9→7.0.10 실서버)에서 자식 프로세스(신코드, root)의
terminating 게시가 소유권 복원 이후 시점에 실행되며 캐시 락 샤드 디렉토리
(storage/framework/cache/data/xx)와 병합 번들(storage/app/ext-bundles)을
root 소유로 남겼고, 이후 웹 프로세스의 캐시 쓰기가 그 샤드에 해시되는 순간
Permission denied 로 죽어 전면 500 이 됐다 (laravel.log 무기록 — 로거 도달 전).

normalizeOwnership 은 게시 트리(build/ext)만 다뤘다 — 게시의 간접 write-set
(캐시 락·번들 빌드)이 사각이었다. 뒤쫓아 chown 하는 대신 root 프로세스에서는
terminating 자동 게시를 예약하지 않고 다음 웹 렌더의 자가 치유(웹 계정)에
위임한다. 명시적 ext-static:publish 는 게이트 밖(운영자 책임 — 규정 §6).

- 시나리오 매니페스트에 process_user(web/root_cli) 축 신설 — uid 조합 실측
 부재가 이 결함을 통과시킨 구조적 원인 (기존 마커 전수에 web 부여)
- red 소급 증빙: 게이트 부재 커밋본에서 신규 케이스 실패 확인 → 15/15 green
2026-08-25 17:37:24 +09:00

113 lines
4.8 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, 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, 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);
});
});