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