feat: 게임 정보에 빌드 커밋 표시

프로필 배포가 고정한 전체 커밋 SHA를 프론트 빌드와 Turbo 캐시 입력에 전달한다. 게임 정보 대화상자와 데스크톱·모바일 Chromium 검증을 함께 보강한다.
This commit is contained in:
2026-08-21 01:28:49 +00:00
parent 360ec20ca7
commit 566df550cf
10 changed files with 158 additions and 11 deletions
+62 -8
View File
@@ -885,9 +885,9 @@ const expectMobilePanelVisualOrder = async (page: Page, expectedOrder: readonly
expect(audit.panels.map(({ id }) => id)).toEqual(expectedOrder);
expect(audit.visualOrder).toEqual(expectedOrder);
expect(audit.panels.every(({ left, right, width }) => left >= 0 && right <= 500 && width === 500)).toBe(true);
expect(
audit.panels.every((panel, index) => index === 0 || panel.top >= audit.panels[index - 1]!.bottom)
).toBe(true);
expect(audit.panels.every((panel, index) => index === 0 || panel.top >= audit.panels[index - 1]!.bottom)).toBe(
true
);
for (const panel of audit.panels) {
expect(panel.display, `${panel.id}: display`).not.toBe('none');
expect(['static', 'relative'], `${panel.id}: position`).toContain(panel.position);
@@ -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,
@@ -1125,9 +1127,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await expect(page.locator('.main-mobile-bottom')).toBeHidden();
await expect(page.locator('.layout-desktop')).toBeVisible();
await expect(page.locator('.layout-mobile')).toHaveCount(0);
await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount(
1
);
await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount(1);
await expect(page.locator('.game-shell__subtitle')).toHaveCount(0);
await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월');
await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분');
@@ -1252,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();
@@ -1279,7 +1307,9 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
});
test('shows the persisted official game index beside the scenario title without viewport overflow', async ({ page }) => {
test('shows the persisted official game index beside the scenario title without viewport overflow', async ({
page,
}) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
@@ -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`);
});
+2 -1
View File
@@ -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: '.',
+1
View File
@@ -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 {
+18
View File
@@ -44,6 +44,7 @@ const isMobile = useMediaQuery('(max-width: 939.98px)');
const npcMode = ref(0);
const globalNavigation = ref<MainNavigationEntry[]>(defaultGlobalNavigation);
const versionDialog = ref<HTMLDialogElement | null>(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(
<h2 id="game-version-title">게임 정보</h2>
<p>{{ lobbyInfo?.scenarioTitle || 'Core2026' }}</p>
<p>삼국지 모의전투 Core2026</p>
<p class="game-version-dialog__commit">
<span>빌드 커밋</span>
<code>{{ buildCommitSha }}</code>
</p>
<form method="dialog"><button class="legacy-button legacy-button--navigation" type="submit">닫기</button></form>
</dialog>
</template>
@@ -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.
+25
View File
@@ -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;
}
});
});
+24
View File
@@ -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,
},
@@ -538,9 +538,13 @@ const buildProfileFrontendOutDir = (workspaceRoot: string, profileName: string):
export const buildProfileFrontendCommands = (
workspaceRoot: string,
profile: Pick<GatewayProfileRecord, 'profileName' | 'profile' | 'apiPort'>,
buildCommitSha: string,
env?: Record<string, string>,
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 ?? {}),
@@ -548,6 +552,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(
@@ -1475,6 +1480,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
...buildProfileFrontendCommands(
workspace.root,
profile,
commitSha,
this.processConfig.baseEnv,
this.processConfig.workspaceRoot
),
@@ -2056,6 +2062,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
? buildProfileFrontendCommands(
workspace.root,
profile,
commitSha,
this.processConfig.baseEnv,
this.processConfig.workspaceRoot
)
+11 -2
View File
@@ -347,9 +347,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',
});
@@ -361,6 +363,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',
@@ -377,10 +380,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.'
);
});
});
@@ -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'],
]);