diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 2d752e4d..81412299 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -1102,7 +1102,9 @@ test('scopes the new-survey notice cursor to the reset-specific server ID', asyn expect(await page.evaluate(() => localStorage.getItem('state.che.lastVote'))).toBe('99'); }); -test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({ page }) => { +test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({ + page, +}, testInfo) => { const state: NavigationFixture = { officerLevel: 5, permission: 2, @@ -1250,6 +1252,34 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro const versionDialog = page.getByRole('dialog', { name: '게임 정보' }); await expect(versionDialog).toBeVisible(); await expect(versionDialog).toContainText('메인 화면 검증 시나리오'); + await expect(versionDialog.getByText('빌드 커밋', { exact: true })).toBeVisible(); + await expect(versionDialog.locator('code')).toHaveText('0123456789abcdef0123456789abcdef01234567'); + const versionGeometry = await versionDialog.evaluate((dialog) => { + const code = dialog.querySelector('code'); + if (!code) throw new Error('game version commit is missing'); + const dialogStyle = getComputedStyle(dialog); + const codeStyle = getComputedStyle(code); + return { + dialog: dialog.getBoundingClientRect().toJSON(), + code: code.getBoundingClientRect().toJSON(), + dialogBackground: dialogStyle.backgroundColor, + dialogColor: dialogStyle.color, + codeColor: codeStyle.color, + codeFontFamily: codeStyle.fontFamily, + viewportWidth: window.innerWidth, + }; + }); + expect(versionGeometry.dialog.width).toBeLessThanOrEqual(versionGeometry.viewportWidth - 32); + expect(versionGeometry.code.left).toBeGreaterThanOrEqual(versionGeometry.dialog.left); + expect(versionGeometry.code.right).toBeLessThanOrEqual(versionGeometry.dialog.right); + expect(versionGeometry.dialogBackground).toBe('rgb(32, 32, 32)'); + expect(versionGeometry.dialogColor).toBe('rgb(255, 255, 255)'); + expect(versionGeometry.codeColor).toBe('rgb(215, 215, 215)'); + await writeFile( + testInfo.outputPath('desktop-game-version-dialog.json'), + `${JSON.stringify(versionGeometry, null, 2)}\n` + ); + await versionDialog.screenshot({ path: testInfo.outputPath('desktop-game-version-dialog.png') }); await versionDialog.getByRole('button', { name: '닫기' }).click(); await expect(versionDialog).toBeHidden(); @@ -1479,6 +1509,30 @@ test('the repeated bottom global menu opens upward on the mobile document', asyn expect(geometry.caretBorderTopWidth).toBe('0px'); expect(geometry.caretBorderBottomWidth).toBe('4px'); await bottomGlobal.screenshot({ path: testInfo.outputPath('mobile-bottom-global-dropup.png') }); + await page.setViewportSize({ width: 390, height: 844 }); + await bottomGlobal.locator('[data-navigation-id="version"]').click(); + const versionDialog = page.getByRole('dialog', { name: '게임 정보' }); + await expect(versionDialog).toBeVisible(); + await expect(versionDialog.locator('code')).toHaveText('0123456789abcdef0123456789abcdef01234567'); + const versionGeometry = await versionDialog.evaluate((dialog) => { + const code = dialog.querySelector('code'); + if (!code) throw new Error('game version commit is missing'); + return { + dialog: dialog.getBoundingClientRect().toJSON(), + code: code.getBoundingClientRect().toJSON(), + viewportWidth: window.innerWidth, + documentScrollWidth: document.documentElement.scrollWidth, + }; + }); + expect(versionGeometry.dialog.width).toBeLessThanOrEqual(versionGeometry.viewportWidth - 32); + expect(versionGeometry.code.left).toBeGreaterThanOrEqual(versionGeometry.dialog.left); + expect(versionGeometry.code.right).toBeLessThanOrEqual(versionGeometry.dialog.right); + expect(versionGeometry.documentScrollWidth).toBe(500); + await writeFile( + testInfo.outputPath('mobile-game-version-dialog.json'), + `${JSON.stringify(versionGeometry, null, 2)}\n` + ); + await versionDialog.screenshot({ path: testInfo.outputPath('mobile-game-version-dialog.png') }); await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-dropup`); }); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 734b6cee..55766660 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -9,11 +9,12 @@ const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default'; const baseURL = `http://127.0.0.1:${port}${basePath}/`; const gameApiUrl = process.env.PLAYWRIGHT_GAME_API_URL ?? `${basePath}/api/trpc`; const gatewayWebUrl = process.env.PLAYWRIGHT_GATEWAY_WEB_URL ?? '/gateway/'; +const buildCommitSha = process.env.PLAYWRIGHT_BUILD_COMMIT_SHA ?? '0123456789abcdef0123456789abcdef01234567'; const useProductionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production'; const frontendEnv = `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} ` + `VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl} ` + - 'VITE_GATEWAY_API_URL=/gateway/api/trpc'; + `VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_BUILD_COMMIT_SHA=${buildCommitSha}`; export default defineConfig({ testDir: '.', diff --git a/app/game-frontend/src/env.d.ts b/app/game-frontend/src/env.d.ts index 2f5bd742..d9367ebf 100644 --- a/app/game-frontend/src/env.d.ts +++ b/app/game-frontend/src/env.d.ts @@ -22,6 +22,7 @@ interface ImportMetaEnv { readonly VITE_BOARD_PATCH_URL?: string; readonly VITE_OFFICIAL_CHAT_URL?: string; readonly VITE_CASUAL_CHAT_URL?: string; + readonly VITE_BUILD_COMMIT_SHA?: string; } interface ImportMeta { diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 93d68c40..c0bcdd72 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -44,6 +44,7 @@ const isMobile = useMediaQuery('(max-width: 939.98px)'); const npcMode = ref(0); const globalNavigation = ref(defaultGlobalNavigation); const versionDialog = ref(null); +const buildCommitSha = import.meta.env.VITE_BUILD_COMMIT_SHA?.trim() || 'unknown'; const mobilePanelOrder = ref(loadMobileMainPanelOrder()); const navigationUrl = (import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc').replace(/\/trpc\/?$/u, '/navigation'); @@ -571,6 +572,10 @@ watch(

게임 정보

{{ lobbyInfo?.scenarioTitle || 'Core2026' }}

삼국지 모의전투 Core2026

+

+ 빌드 커밋 + {{ buildCommitSha }} +

@@ -584,6 +589,7 @@ button { } .game-version-dialog { + box-sizing: border-box; width: min(420px, calc(100vw - 32px)); border: 1px solid #555; border-radius: 4px; @@ -607,6 +613,18 @@ button { justify-content: center; } +.game-version-dialog__commit { + display: flex; + flex-direction: column; + gap: 4px; +} + +.game-version-dialog__commit code { + overflow-wrap: anywhere; + color: #d7d7d7; + font-size: 0.85em; +} + /* * Ref's main document does not clip horizontally; the map panel below manages * its own overflow. diff --git a/app/game-frontend/test/viteConfig.test.ts b/app/game-frontend/test/viteConfig.test.ts index 79407549..10cc4167 100644 --- a/app/game-frontend/test/viteConfig.test.ts +++ b/app/game-frontend/test/viteConfig.test.ts @@ -28,4 +28,29 @@ void describe('game frontend Vite config', () => { assert.equal(loaded?.config.build?.sourcemap, true); }); + + void it('uses the deployment-pinned full commit SHA as the displayed build version', async () => { + const commitSha = 'ABCDEF0123456789ABCDEF0123456789ABCDEF01'; + const previousCommitSha = process.env.VITE_BUILD_COMMIT_SHA; + process.env.VITE_BUILD_COMMIT_SHA = commitSha; + try { + const configPath = path.resolve(import.meta.dirname, '../vite.config.ts'); + const loaded = await loadConfigFromFile( + { command: 'build', mode: 'production' }, + configPath, + path.dirname(configPath), + undefined, + undefined, + 'runner' + ); + + assert.equal( + loaded?.config.define?.['import.meta.env.VITE_BUILD_COMMIT_SHA'], + JSON.stringify(commitSha.toLowerCase()) + ); + } finally { + if (previousCommitSha === undefined) delete process.env.VITE_BUILD_COMMIT_SHA; + else process.env.VITE_BUILD_COMMIT_SHA = previousCommitSha; + } + }); }); diff --git a/app/game-frontend/vite.config.ts b/app/game-frontend/vite.config.ts index 0582271d..36e2526b 100644 --- a/app/game-frontend/vite.config.ts +++ b/app/game-frontend/vite.config.ts @@ -1,9 +1,29 @@ import { defineConfig, loadEnv } 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'; +const fullCommitShaPattern = /^[0-9a-f]{40,64}$/iu; + +export const resolveBuildCommitSha = (explicitSha: string | undefined, repositoryRoot: string): string => { + const normalizedExplicitSha = explicitSha?.trim(); + if (normalizedExplicitSha && fullCommitShaPattern.test(normalizedExplicitSha)) { + return normalizedExplicitSha.toLowerCase(); + } + try { + const repositorySha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repositoryRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return fullCommitShaPattern.test(repositorySha) ? repositorySha.toLowerCase() : 'unknown'; + } catch { + return 'unknown'; + } +}; + const normalizeBasePath = (value: string | undefined): string => { const pathValue = (value ?? '/').trim(); if (!pathValue || pathValue === '/') { @@ -27,9 +47,13 @@ const resolvePreviewAllowedHosts = (value: string | undefined): true | string[] // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { const env = mergeViteEnv(loadEnv(mode, process.cwd(), ''), process.env); + const buildCommitSha = resolveBuildCommitSha(env.VITE_BUILD_COMMIT_SHA, path.resolve(import.meta.dirname, '../..')); return { base: normalizeBasePath(env.VITE_APP_BASE_PATH), plugins: [vue(), tailwindcss()], + define: { + 'import.meta.env.VITE_BUILD_COMMIT_SHA': JSON.stringify(buildCommitSha), + }, build: { sourcemap: true, }, diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 32d1b86c..0c5dfdf2 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -552,9 +552,13 @@ const buildProfileFrontendOutDir = (workspaceRoot: string, profileName: string): export const buildProfileFrontendCommands = ( workspaceRoot: string, profile: Pick, + buildCommitSha: string, env?: Record, cacheAnchorRoot: string = workspaceRoot ): BuildCommand[] => { + if (!/^[0-9a-f]{40,64}$/iu.test(buildCommitSha.trim())) { + throw new Error('Profile frontend build requires a full commit SHA.'); + } const profileFrontendBuildNodeOptions = env?.PROFILE_FRONTEND_BUILD_NODE_OPTIONS?.trim(); const buildEnv = { ...(env ?? {}), @@ -562,6 +566,7 @@ export const buildProfileFrontendCommands = ( VITE_APP_BASE_PATH: `/${profile.profile}`, VITE_GAME_API_URL: `/${profile.profile}/api/trpc`, VITE_GAME_SSE_URL: `/${profile.profile}/api/events`, + VITE_BUILD_COMMIT_SHA: buildCommitSha.trim().toLowerCase(), }; return [ buildTurboReleaseTaskCommand( @@ -1503,6 +1508,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { ...buildProfileFrontendCommands( workspace.root, profile, + commitSha, this.processConfig.baseEnv, this.processConfig.workspaceRoot ), @@ -2090,6 +2096,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { ? buildProfileFrontendCommands( workspace.root, profile, + commitSha, this.processConfig.baseEnv, this.processConfig.workspaceRoot ) diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 6fdbda66..2a29a838 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -371,9 +371,11 @@ describe('buildWorkspaceCommands', () => { }); describe('buildProfileFrontendCommands', () => { + const buildCommitSha = '0123456789abcdef0123456789abcdef01234567'; + it('uses a profile frontend build-only Node heap without changing the shared runtime heap', () => { const workspaceRoot = '/srv/sammo/worktrees/0123456789abcdef'; - const commands = buildProfileFrontendCommands(workspaceRoot, buildProfile(), { + const commands = buildProfileFrontendCommands(workspaceRoot, buildProfile(), buildCommitSha, { NODE_OPTIONS: '--max-old-space-size=1536', PROFILE_FRONTEND_BUILD_NODE_OPTIONS: '--max-old-space-size=2048', }); @@ -385,6 +387,7 @@ describe('buildProfileFrontendCommands', () => { (command) => command.env?.PROFILE_FRONTEND_BUILD_NODE_OPTIONS === '--max-old-space-size=2048' ) ).toBe(true); + expect(commands.every((command) => command.env?.VITE_BUILD_COMMIT_SHA === buildCommitSha)).toBe(true); expect(commands[0]?.args).toEqual([ 'exec', 'turbo', @@ -401,10 +404,16 @@ describe('buildProfileFrontendCommands', () => { it('keeps the shared Node heap when no frontend build override is configured', () => { const workspaceRoot = '/srv/sammo/worktrees/0123456789abcdef'; - const commands = buildProfileFrontendCommands(workspaceRoot, buildProfile(), { + const commands = buildProfileFrontendCommands(workspaceRoot, buildProfile(), buildCommitSha, { NODE_OPTIONS: '--max-old-space-size=1536', }); expect(commands.every((command) => command.env?.NODE_OPTIONS === '--max-old-space-size=1536')).toBe(true); }); + + it('rejects a non-commit build version before creating cached frontend commands', () => { + expect(() => buildProfileFrontendCommands('/srv/sammo/worktrees/main', buildProfile(), 'main')).toThrow( + 'Profile frontend build requires a full commit SHA.' + ); + }); }); diff --git a/app/gateway-api/test/profileDeployOperation.test.ts b/app/gateway-api/test/profileDeployOperation.test.ts index 1cb35a42..7feecf1e 100644 --- a/app/gateway-api/test/profileDeployOperation.test.ts +++ b/app/gateway-api/test/profileDeployOperation.test.ts @@ -194,6 +194,8 @@ describe('profile DEPLOY operation', () => { 'tools/build-scripts/materialize-profile-frontend.mjs', 'che:1010', ]); + expect(commandGroups[0]?.[2]?.env?.VITE_BUILD_COMMIT_SHA).toBe(SHA); + expect(commandGroups[0]?.[3]?.env?.VITE_BUILD_COMMIT_SHA).toBe(SHA); expect(commandGroups[1]?.map((command) => command.args)).toEqual([ ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], ]); diff --git a/docs/architecture/runtime.md b/docs/architecture/runtime.md index 58ad14b8..5e1faf54 100644 --- a/docs/architecture/runtime.md +++ b/docs/architecture/runtime.md @@ -125,6 +125,12 @@ commit의 game API, engine과 profile 전용 frontend artifact를 빌드합니 프로세스를 멈춘 뒤 `prisma migrate deploy`만 실행하고 seed는 호출하지 않습니다. 새 API·frontend와 모든 worker가 PM2 `online`이고 HTTP readiness가 성공해야 build commit을 게시합니다. 실패하면 이전 worktree 프로세스를 다시 시작합니다. +Profile frontend build에는 같은 전체 commit SHA를 `VITE_BUILD_COMMIT_SHA`로 +주입합니다. 이 값은 Turbo의 `VITE_*` cache key에 포함되고 Vite가 bundle 상수로 +고정하므로, 게임의 `게임 정보` dialog가 실제 선택 build commit을 표시하며 다른 +commit의 cached artifact를 현재 버전으로 오인하지 않습니다. Orchestrator 밖의 +개발 build는 현재 Git checkout의 `HEAD`를 fallback으로 사용하고 Git metadata를 +읽을 수 없을 때만 `unknown`을 표시합니다. `RESET` operation은 같은 build 경계를 사용한 뒤 현재 시즌 테이블을 seed로 교체합니다. Seeder의 reset 목록에는 `hall`, `ng_games`, `yearbook_history`, 과거 장수·국가와 상속·진단 자료가 포함되지 않습니다.