feat: profile 새 버전 안내 toast를 추가한다
배포 bundle과 같은 commit의 정적 버전 문서를 생성하고, 열린 탭이 DB mutation 없이 변경을 감지하도록 한다. 동일 버전은 tab session에서 한 번만 안내하며 강제 새로고침은 하지 않는다.
This commit is contained in:
@@ -85,7 +85,8 @@ Node.js는 `.nvmrc`에서 24.x로 고정합니다. 의존성 설치와 검증에
|
||||
`package.json`과 `pnpm-lock.yaml`을 함께 사용해 주세요. 모든 workspace package는
|
||||
내부 전용(`private`)이므로 manifest의 `0.0.0`은 배포 버전이 아닙니다. 배포 source는
|
||||
full Git commit으로 고정하고, 실험 릴리스 같은 milestone은 annotated Git tag로
|
||||
식별합니다.
|
||||
식별합니다. Profile frontend는 같은 commit의 정적 버전 문서를 주기적으로 확인하고,
|
||||
현재 열린 bundle과 달라졌을 때만 강제 reload 없이 공용 toast로 한 번 안내합니다.
|
||||
|
||||
## 개발 환경
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { gamePath } from './gameTestPaths.js';
|
||||
|
||||
const currentCommitSha = process.env.PLAYWRIGHT_BUILD_COMMIT_SHA ?? '0123456789abcdef0123456789abcdef01234567';
|
||||
const nextCommitSha = '89abcdef0123456789abcdef0123456789abcdef';
|
||||
const noticeMessage = '새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.';
|
||||
|
||||
const installVersionFixture = async (page: Page) => {
|
||||
let availableCommitSha = currentCommitSha;
|
||||
let requests = 0;
|
||||
await page.route('**/deployment-version.json*', async (route) => {
|
||||
requests += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ commitSha: availableCommitSha }),
|
||||
});
|
||||
});
|
||||
return {
|
||||
deployNextVersion: () => {
|
||||
availableCommitSha = nextCommitSha;
|
||||
},
|
||||
requestCount: () => requests,
|
||||
};
|
||||
};
|
||||
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1280, height: 800 },
|
||||
{ name: 'mobile', width: 390, height: 844 },
|
||||
]) {
|
||||
test(`shows one quiet update toast without forcing reload on ${viewport.name}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
const fixture = await installVersionFixture(page);
|
||||
await page.goto(gamePath('/version-notice-fixture'));
|
||||
await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible();
|
||||
await expect.poll(fixture.requestCount).toBeGreaterThan(0);
|
||||
await page.evaluate(() => {
|
||||
Object.assign(window, { __versionNoticePageMarker: 'kept' });
|
||||
});
|
||||
|
||||
fixture.deployNextVersion();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('online')));
|
||||
return fixture.requestCount();
|
||||
})
|
||||
.toBeGreaterThan(1);
|
||||
const toast = page.getByTestId('game-toast').filter({ hasText: noticeMessage });
|
||||
await expect(toast).toBeVisible();
|
||||
await expect(toast).toHaveAttribute('data-feedback-kind', 'info');
|
||||
await expect(toast).toHaveCSS('transform', 'none');
|
||||
const box = await toast.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.y).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(box!.y + box!.height).toBeLessThanOrEqual(viewport.height);
|
||||
expect(await page.evaluate(() => Reflect.get(window, '__versionNoticePageMarker'))).toBe('kept');
|
||||
|
||||
await page.getByRole('button', { name: '알림 닫기' }).click();
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('online')));
|
||||
await expect(toast).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export default defineConfig({
|
||||
'session-auth.spec.ts',
|
||||
'npcPossession.spec.ts',
|
||||
'joinLayout.spec.ts',
|
||||
'deploymentVersionNotice.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue';
|
||||
import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice';
|
||||
|
||||
useDeploymentVersionNotice();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { onBeforeUnmount, onMounted } from 'vue';
|
||||
import { createDeploymentVersionChecker } from '../config/deploymentVersion';
|
||||
import { useGameFeedback } from './useGameFeedback';
|
||||
|
||||
const pollIntervalMs = 60_000;
|
||||
const noticeMessage = '새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.';
|
||||
|
||||
const resolveSessionStorage = (): Pick<Storage, 'getItem' | 'setItem'> | undefined => {
|
||||
try {
|
||||
return window.sessionStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const useDeploymentVersionNotice = (): void => {
|
||||
const currentCommitSha = import.meta.env.VITE_BUILD_COMMIT_SHA?.trim() ?? '';
|
||||
const versionUrl = `${import.meta.env.BASE_URL}deployment-version.json`;
|
||||
const { info: showInfoToast } = useGameFeedback();
|
||||
const checker = createDeploymentVersionChecker({
|
||||
currentCommitSha,
|
||||
versionUrl,
|
||||
storage: resolveSessionStorage(),
|
||||
onVersionChanged: () => showInfoToast(noticeMessage),
|
||||
});
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const checkWhenVisible = (): void => {
|
||||
if (document.visibilityState === 'visible') void checker.check();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void checker.check();
|
||||
pollTimer = setInterval(checkWhenVisible, pollIntervalMs);
|
||||
document.addEventListener('visibilitychange', checkWhenVisible);
|
||||
window.addEventListener('online', checkWhenVisible);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
document.removeEventListener('visibilitychange', checkWhenVisible);
|
||||
window.removeEventListener('online', checkWhenVisible);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
const fullCommitShaPattern = /^[0-9a-f]{40,64}$/iu;
|
||||
|
||||
type VersionStorage = Pick<Storage, 'getItem' | 'setItem'>;
|
||||
|
||||
export type DeploymentVersionCheckerOptions = {
|
||||
currentCommitSha: string;
|
||||
versionUrl: string;
|
||||
fetchVersion?: typeof fetch;
|
||||
storage?: VersionStorage;
|
||||
now?: () => number;
|
||||
onVersionChanged: (availableCommitSha: string) => void;
|
||||
};
|
||||
|
||||
export const deploymentVersionAssetSource = (buildCommitSha: string): string =>
|
||||
`${JSON.stringify({ commitSha: buildCommitSha })}\n`;
|
||||
|
||||
export const parseDeploymentCommitSha = (payload: unknown): string | null => {
|
||||
if (!payload || typeof payload !== 'object' || !('commitSha' in payload)) return null;
|
||||
const commitSha = String(payload.commitSha).trim().toLowerCase();
|
||||
return fullCommitShaPattern.test(commitSha) ? commitSha : null;
|
||||
};
|
||||
|
||||
const notificationStorageKey = (versionUrl: string, availableCommitSha: string): string =>
|
||||
`sammo:deployment-version-notice:${versionUrl}:${availableCommitSha}`;
|
||||
|
||||
export const createDeploymentVersionChecker = (options: DeploymentVersionCheckerOptions) => {
|
||||
const currentCommitSha = options.currentCommitSha.trim().toLowerCase();
|
||||
const fetchVersion = options.fetchVersion ?? fetch;
|
||||
const now = options.now ?? Date.now;
|
||||
let inFlight: Promise<void> | null = null;
|
||||
let lastNotifiedCommitSha: string | null = null;
|
||||
|
||||
const wasNotified = (key: string): boolean => {
|
||||
try {
|
||||
return options.storage?.getItem(key) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const rememberNotification = (key: string): void => {
|
||||
try {
|
||||
options.storage?.setItem(key, '1');
|
||||
} catch {
|
||||
// Session storage can be unavailable under restrictive browser policies.
|
||||
}
|
||||
};
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
if (!fullCommitShaPattern.test(currentCommitSha)) return;
|
||||
const separator = options.versionUrl.includes('?') ? '&' : '?';
|
||||
const response = await fetchVersion(`${options.versionUrl}${separator}t=${now()}`, {
|
||||
cache: 'no-store',
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const availableCommitSha = parseDeploymentCommitSha(await response.json());
|
||||
if (!availableCommitSha || availableCommitSha === currentCommitSha) return;
|
||||
|
||||
const storageKey = notificationStorageKey(options.versionUrl, availableCommitSha);
|
||||
if (lastNotifiedCommitSha === availableCommitSha || wasNotified(storageKey)) return;
|
||||
lastNotifiedCommitSha = availableCommitSha;
|
||||
rememberNotification(storageKey);
|
||||
options.onVersionChanged(availableCommitSha);
|
||||
};
|
||||
|
||||
return {
|
||||
check: (): Promise<void> => {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = run()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
return inFlight;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createDeploymentVersionChecker,
|
||||
deploymentVersionAssetSource,
|
||||
parseDeploymentCommitSha,
|
||||
} from '../src/config/deploymentVersion.ts';
|
||||
|
||||
const currentCommitSha = '0123456789abcdef0123456789abcdef01234567';
|
||||
const nextCommitSha = '89abcdef0123456789abcdef0123456789abcdef';
|
||||
const laterCommitSha = 'fedcba9876543210fedcba9876543210fedcba98';
|
||||
|
||||
const jsonResponse = (commitSha: string): Response =>
|
||||
new Response(JSON.stringify({ commitSha }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
void test('accepts only full hexadecimal deployment commit values', () => {
|
||||
assert.equal(parseDeploymentCommitSha({ commitSha: nextCommitSha.toUpperCase() }), nextCommitSha);
|
||||
assert.equal(parseDeploymentCommitSha({ commitSha: 'main' }), null);
|
||||
assert.equal(parseDeploymentCommitSha({ version: nextCommitSha }), null);
|
||||
assert.equal(parseDeploymentCommitSha(null), null);
|
||||
});
|
||||
|
||||
void test('exposes only the read-only build commit in the deployment version asset', () => {
|
||||
assert.deepEqual(JSON.parse(deploymentVersionAssetSource(currentCommitSha)), { commitSha: currentCommitSha });
|
||||
});
|
||||
|
||||
void test('notifies once per available version and bypasses browser caches', async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const stored = new Map<string, string>();
|
||||
const notifications: string[] = [];
|
||||
let availableCommitSha = currentCommitSha;
|
||||
const checker = createDeploymentVersionChecker({
|
||||
currentCommitSha,
|
||||
versionUrl: '/che/deployment-version.json',
|
||||
now: () => 1234,
|
||||
storage: {
|
||||
getItem: (key) => stored.get(key) ?? null,
|
||||
setItem: (key, value) => void stored.set(key, value),
|
||||
},
|
||||
fetchVersion: async (url, init) => {
|
||||
requests.push({ url: String(url), init });
|
||||
return jsonResponse(availableCommitSha);
|
||||
},
|
||||
onVersionChanged: (commitSha) => notifications.push(commitSha),
|
||||
});
|
||||
|
||||
await checker.check();
|
||||
availableCommitSha = nextCommitSha;
|
||||
await checker.check();
|
||||
await checker.check();
|
||||
availableCommitSha = laterCommitSha;
|
||||
await checker.check();
|
||||
|
||||
assert.deepEqual(notifications, [nextCommitSha, laterCommitSha]);
|
||||
assert.equal(requests.every(({ url }) => url === '/che/deployment-version.json?t=1234'), true);
|
||||
assert.equal(requests.every(({ init }) => init?.cache === 'no-store'), true);
|
||||
assert.equal(requests.every(({ init }) => new Headers(init?.headers).get('Cache-Control') === 'no-cache'), true);
|
||||
});
|
||||
|
||||
void test('shares the once-only notice within the current tab session', async () => {
|
||||
const stored = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (key: string) => stored.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void stored.set(key, value),
|
||||
};
|
||||
let notifications = 0;
|
||||
const options = {
|
||||
currentCommitSha,
|
||||
versionUrl: '/hwe/deployment-version.json',
|
||||
storage,
|
||||
fetchVersion: async () => jsonResponse(nextCommitSha),
|
||||
onVersionChanged: () => notifications++,
|
||||
};
|
||||
|
||||
await createDeploymentVersionChecker(options).check();
|
||||
await createDeploymentVersionChecker(options).check();
|
||||
|
||||
assert.equal(notifications, 1);
|
||||
});
|
||||
|
||||
void test('ignores unavailable or malformed version documents without disrupting the page', async () => {
|
||||
let notifications = 0;
|
||||
const unavailable = createDeploymentVersionChecker({
|
||||
currentCommitSha,
|
||||
versionUrl: '/che/deployment-version.json',
|
||||
fetchVersion: async () => {
|
||||
throw new Error('offline');
|
||||
},
|
||||
onVersionChanged: () => notifications++,
|
||||
});
|
||||
const malformed = createDeploymentVersionChecker({
|
||||
currentCommitSha,
|
||||
versionUrl: '/che/deployment-version.json',
|
||||
fetchVersion: async () => new Response('{', { status: 200 }),
|
||||
onVersionChanged: () => notifications++,
|
||||
});
|
||||
|
||||
await unavailable.check();
|
||||
await malformed.check();
|
||||
assert.equal(notifications, 0);
|
||||
});
|
||||
@@ -48,6 +48,17 @@ void describe('game frontend Vite config', () => {
|
||||
loaded?.config.define?.['import.meta.env.VITE_BUILD_COMMIT_SHA'],
|
||||
JSON.stringify(commitSha.toLowerCase())
|
||||
);
|
||||
assert.equal(
|
||||
loaded?.config.plugins?.some(
|
||||
(plugin) =>
|
||||
plugin !== null &&
|
||||
typeof plugin === 'object' &&
|
||||
!Array.isArray(plugin) &&
|
||||
'name' in plugin &&
|
||||
plugin.name === 'sammo-deployment-version'
|
||||
),
|
||||
true
|
||||
);
|
||||
} finally {
|
||||
if (previousCommitSha === undefined) delete process.env.VITE_BUILD_COMMIT_SHA;
|
||||
else process.env.VITE_BUILD_COMMIT_SHA = previousCommitSha;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import { defineConfig, loadEnv, type Plugin } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'path';
|
||||
import { mergeViteEnv } from './src/config/viteEnv';
|
||||
import { deploymentVersionAssetSource } from './src/config/deploymentVersion.ts';
|
||||
import { mergeViteEnv } from './src/config/viteEnv.ts';
|
||||
|
||||
const fullCommitShaPattern = /^[0-9a-f]{40,64}$/iu;
|
||||
|
||||
@@ -24,6 +25,17 @@ export const resolveBuildCommitSha = (explicitSha: string | undefined, repositor
|
||||
}
|
||||
};
|
||||
|
||||
export const createDeploymentVersionPlugin = (buildCommitSha: string): Plugin => ({
|
||||
name: 'sammo-deployment-version',
|
||||
generateBundle() {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: 'deployment-version.json',
|
||||
source: deploymentVersionAssetSource(buildCommitSha),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const normalizeBasePath = (value: string | undefined): string => {
|
||||
const pathValue = (value ?? '/').trim();
|
||||
if (!pathValue || pathValue === '/') {
|
||||
@@ -50,7 +62,7 @@ export default defineConfig(({ mode }) => {
|
||||
const buildCommitSha = resolveBuildCommitSha(env.VITE_BUILD_COMMIT_SHA, path.resolve(import.meta.dirname, '../..'));
|
||||
return {
|
||||
base: normalizeBasePath(env.VITE_APP_BASE_PATH),
|
||||
plugins: [vue(), tailwindcss()],
|
||||
plugins: [vue(), tailwindcss(), createDeploymentVersionPlugin(buildCommitSha)],
|
||||
define: {
|
||||
'import.meta.env.VITE_BUILD_COMMIT_SHA': JSON.stringify(buildCommitSha),
|
||||
},
|
||||
|
||||
@@ -23,11 +23,15 @@ describe('profile frontend materializer', () => {
|
||||
await mkdir(source, { recursive: true });
|
||||
await mkdir(target, { recursive: true });
|
||||
await writeFile(path.join(source, 'index.html'), 'new release');
|
||||
await writeFile(path.join(source, 'deployment-version.json'), '{"commitSha":"new"}\n');
|
||||
await writeFile(path.join(target, 'index.html'), 'old release');
|
||||
|
||||
await execFileAsync(process.execPath, [materializer, 'che:2'], { cwd: workspaceRoot });
|
||||
|
||||
await expect(readFile(path.join(target, 'index.html'), 'utf8')).resolves.toBe('new release');
|
||||
await expect(readFile(path.join(target, 'deployment-version.json'), 'utf8')).resolves.toBe(
|
||||
'{"commitSha":"new"}\n'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an existing artifact untouched when the cached build is missing', async () => {
|
||||
|
||||
@@ -136,6 +136,16 @@ Profile frontend build에는 같은 전체 commit SHA를 `VITE_BUILD_COMMIT_SHA`
|
||||
commit의 cached artifact를 현재 버전으로 오인하지 않습니다. Orchestrator 밖의
|
||||
개발 build는 현재 Git checkout의 `HEAD`를 fallback으로 사용하고 Git metadata를
|
||||
읽을 수 없을 때만 `unknown`을 표시합니다.
|
||||
같은 build 단계는 profile root에 `deployment-version.json`을 생성합니다. 이미 열린
|
||||
game frontend는 bundle에 고정된 commit과 이 정적 문서의 commit을 최초 mount,
|
||||
60초 주기, tab visibility 복귀와 network online 복귀 때 비교합니다. 다른 full SHA를
|
||||
처음 관찰하면 공용 info toast로 새로고침 안내만 하고 reload를 강제하지 않습니다.
|
||||
조회에는 cache-busting query와 `cache: no-store`를 사용하며, 실패·잘못된 문서는
|
||||
현재 화면을 방해하지 않고 무시합니다. 알림 여부는 tab session storage에만 남으므로
|
||||
Gateway/game DB, Redis, 인증, 턴 처리에는 mutation이 없습니다. Profile `DEPLOY`가
|
||||
API·engine·frontend를 같은 commit worktree에서 함께 전환하므로 이 frontend 문서는
|
||||
해당 profile의 새 backend/frontend release가 readiness를 통과해 실제 서빙되기 시작한
|
||||
뒤에만 달라집니다.
|
||||
`RESET` operation은 같은 build 경계를 사용한 뒤 현재 시즌 테이블을 seed로
|
||||
교체합니다. Seeder의 reset 목록에는 `hall`, `ng_games`, `yearbook_history`,
|
||||
과거 장수·국가와 상속·진단 자료가 포함되지 않습니다.
|
||||
|
||||
@@ -140,6 +140,11 @@ path별 Turbo cache에 저장됩니다. Orchestrator는 cache 복원 후 이를
|
||||
`.release-dist/<profileName>/game-frontend`에 staging directory를 거쳐 교체합니다.
|
||||
따라서 같은 commit·같은 공개 prefix의 재배포는 `vue-tsc`와 Vite를 다시 실행하지
|
||||
않고, 여러 instance가 같은 prefix를 쓰더라도 각 runtime target은 따로 materialize됩니다.
|
||||
각 artifact의 `deployment-version.json`은 bundle과 같은 full commit SHA만 담습니다.
|
||||
열린 profile 탭은 이 파일을 cache 우회로 확인해 bundle SHA와 달라졌을 때 한 번만
|
||||
`새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.` toast를 표시합니다.
|
||||
서버는 reload를 강제하지 않으며 이 확인은 정적 GET과 tab session storage만 사용해
|
||||
game/Gateway DB와 Redis에 영향을 주지 않습니다.
|
||||
`RESET`은 선택 worktree의 Gateway profile-seed CLI도 실행하므로 기존처럼
|
||||
`gateway-api`까지 server build에 포함하고, seed를 호출하지 않는 `DEPLOY`만 명시적
|
||||
server target을 `game-api`로 제한합니다.
|
||||
|
||||
Reference in New Issue
Block a user