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 회피
205 lines
9.0 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|