From 94b5b7813cee33ebc8ec1bd290bdb018df027c93 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 13 Aug 2026 15:17:38 +0000 Subject: [PATCH 1/6] feat(gateway): expand release error details --- .../e2e/server-operations.spec.ts | 128 ++++++++++++++++++ .../src/views/ServerOperationsView.vue | 122 ++++++++++++----- 2 files changed, 218 insertions(+), 32 deletions(-) diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index 2d0796d7..3da117a6 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -26,6 +26,9 @@ type FixtureState = { status: OperationStatus; sourceMode?: 'BRANCH' | 'COMMIT'; sourceRef?: string; + resolvedCommitSha?: string; + completedAt?: string; + error?: string; payload: Record; requestedBy: string; createdAt: string; @@ -877,6 +880,131 @@ test('controls gateway deployment and rollback through the external controller q expect(state.requestBodies.some((entry) => entry.operation === 'admin.releases.requestGatewayRollback')).toBe(true); }); +test('moves long Gateway release errors out of the table column into an expandable detail row', async ({ + page, +}, testInfo) => { + const longError = [ + 'Gateway release did not become ready before the timeout.', + 'Error: gateway-frontend readiness check failed after 30 attempts', + ' at waitForGatewayReadiness (/srv/core/release-controller/dist/releaseController.js:842:19)', + 'controller-output-without-breaks-'.repeat(12), + ].join('\n'); + const operationId = '88888888-8888-4888-8888-888888888888'; + const state: FixtureState = { + operations: [], + gatewayOperations: [ + { + id: operationId, + type: 'DEPLOY', + status: 'FAILED', + sourceMode: 'COMMIT', + sourceRef: 'cccccccccccccccccccccccccccccccccccccccc', + resolvedCommitSha: 'cccccccccccccccccccccccccccccccccccccccc', + completedAt: '2026-08-01T02:03:00.000Z', + error: longError, + payload: {}, + requestedBy: 'admin', + createdAt: '2026-08-01T02:00:00.000Z', + updatedAt: '2026-08-01T02:03:00.000Z', + }, + ], + gatewayLogsEmpty: true, + runtimeRunning: true, + requestBodies: [], + }; + await installFixture(page, state); + + await page.goto('admin/releases'); + const table = page.getByTestId('gateway-release-table'); + await expect(table.getByRole('columnheader')).toHaveCount(6); + await expect(table.getByRole('columnheader', { name: '오류', exact: true })).toHaveCount(0); + await expect(table.getByRole('columnheader', { name: '상세', exact: true })).toBeVisible(); + + const errorToggle = page.getByTestId('gateway-release-error-toggle'); + await expect(errorToggle).toHaveText('오류 보기'); + await expect(errorToggle).toHaveAttribute('aria-expanded', 'false'); + await expect(page.getByTestId('gateway-release-error-detail')).toBeHidden(); + + await errorToggle.focus(); + const focusedToggleStyle = await errorToggle.evaluate((element) => { + const style = getComputedStyle(element); + return { + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + color: style.color, + }; + }); + expect(focusedToggleStyle.outlineStyle).not.toBe('none'); + expect(parseFloat(focusedToggleStyle.outlineWidth)).toBeGreaterThanOrEqual(2); + + await errorToggle.hover(); + await errorToggle.click(); + await expect(errorToggle).toHaveText('오류 닫기'); + await expect(errorToggle).toHaveAttribute('aria-expanded', 'true'); + const errorDetail = page.getByTestId('gateway-release-error-detail'); + await expect(errorDetail).toContainText('Gateway release did not become ready'); + await expect(errorDetail).toContainText('controller-output-without-breaks'); + const desktopGeometry = await table.evaluate((element) => { + const headings = Array.from(element.querySelectorAll('thead th')); + const detail = element.querySelector('[data-testid="gateway-release-error-detail"]'); + const detailCell = detail?.querySelector('td'); + const errorText = detail?.querySelector('pre'); + return { + tableWidth: element.getBoundingClientRect().width, + scrollerWidth: element.parentElement?.getBoundingClientRect().width ?? 0, + tableLayout: getComputedStyle(element).tableLayout, + columnCount: headings.length, + detailColSpan: detailCell?.getAttribute('colspan'), + detailWidth: detailCell?.getBoundingClientRect().width ?? 0, + errorWhiteSpace: errorText ? getComputedStyle(errorText).whiteSpace : '', + errorOverflowWrap: errorText ? getComputedStyle(errorText).overflowWrap : '', + }; + }); + expect(desktopGeometry).toMatchObject({ + tableLayout: 'fixed', + columnCount: 6, + detailColSpan: '6', + errorWhiteSpace: 'pre-wrap', + }); + expect(desktopGeometry.tableWidth).toBeGreaterThanOrEqual(680); + expect(desktopGeometry.detailWidth).toBeGreaterThanOrEqual(desktopGeometry.tableWidth - 1); + await page.screenshot({ path: testInfo.outputPath('gateway-release-error-expanded-desktop.png'), fullPage: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(table.getByRole('columnheader')).toHaveCount(4); + await expect(table.getByRole('columnheader', { name: '소스', exact: true })).toHaveCount(0); + await expect(table.getByRole('columnheader', { name: '해석 커밋', exact: true })).toHaveCount(0); + const mobileGeometry = await table.evaluate((element) => { + const scroller = element.parentElement!; + const scrollerRect = scroller.getBoundingClientRect(); + const detailRect = element + .querySelector('[data-testid="gateway-release-error-detail"]')! + .getBoundingClientRect(); + return { + tableWidth: element.getBoundingClientRect().width, + scrollerX: scrollerRect.x, + scrollerWidth: scrollerRect.width, + scrollerScrollWidth: scroller.scrollWidth, + detailWidth: detailRect.width, + viewportWidth: document.documentElement.clientWidth, + documentScrollWidth: document.documentElement.scrollWidth, + }; + }); + expect(mobileGeometry.tableWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1); + expect(mobileGeometry.detailWidth).toBeGreaterThanOrEqual(mobileGeometry.tableWidth - 1); + expect(mobileGeometry.scrollerScrollWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1); + expect(mobileGeometry.scrollerX).toBeGreaterThanOrEqual(0); + expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual( + mobileGeometry.viewportWidth + ); + expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth); + await page.screenshot({ path: testInfo.outputPath('gateway-release-error-expanded-mobile.png'), fullPage: true }); + + await errorToggle.click(); + await expect(errorToggle).toHaveAttribute('aria-expanded', 'false'); + await expect(page.getByTestId('gateway-release-error-detail')).toBeHidden(); +}); + test('explains terminal releases created before controller progress logging', async ({ page }) => { const state: FixtureState = { operations: [], diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index 2a3cb699..1894aa0b 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -88,6 +88,7 @@ const profileOperationLogViewport = ref(); const gatewayReleaseState = ref(null); const gatewayReleaseOperations = ref([]); const selectedGatewayOperationId = ref(''); +const expandedGatewayErrorOperationId = ref(''); const gatewayReleaseLogs = ref([]); const gatewayReleaseLogCursor = ref(); const gatewayReleaseLogStatus = ref(''); @@ -169,7 +170,7 @@ const gatewayReleaseLogEmptyMessage = computed(() => { return 'controller 로그를 기다리고 있습니다…'; } if (operation.error) { - return `이 작업에는 controller 로그가 기록되지 않았습니다. 작업 오류: ${operation.error}`; + return '이 작업에는 controller 로그가 기록되지 않았습니다. 작업 이력의 오류 상세를 확인하세요.'; } return '이 작업에는 controller 로그가 기록되지 않았습니다. 로그 지원 controller 적용 전 작업일 수 있습니다.'; }); @@ -444,6 +445,11 @@ const selectGatewayReleaseOperation = (operationId: string) => { selectedGatewayOperationId.value = operationId; }; +const toggleGatewayReleaseError = (operationId: string) => { + expandedGatewayErrorOperationId.value = + expandedGatewayErrorOperationId.value === operationId ? '' : operationId; +}; + const requestDeploy = async () => { clearStatus(); if ( @@ -1136,45 +1142,97 @@ onBeforeUnmount(() => {
- +
+ + + + + + + + - - - - + + + - - - - - - - - - +
시각 작업 상태소스해석 커밋오류로그상세
{{ formatTime(operation.createdAt) }}{{ operation.type }}{{ operation.status }}{{ operation.sourceRef }}{{ shortSha(operation.resolvedCommitSha) }}{{ operation.error }} - -
From 66d80a829d07153d9a27c5b52d6d2f94e39cc3ab Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 13 Aug 2026 15:24:29 +0000 Subject: [PATCH 2/6] fix(game-ui): fit 500px canvas on mobile entry --- app/game-frontend/e2e/mainNavigation.spec.ts | 119 +++++++++++++++++++ app/game-frontend/index.html | 2 +- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 0a2e49e3..60c918ea 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -1175,6 +1175,125 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy await persistArtifact(page, `${basePath.slice(1)}-mobile-500`); }); +test('real mobile devices initially fit the complete 500px game canvas', async ({ browser }, testInfo) => { + test.setTimeout(60_000); + const configuredBaseUrl = testInfo.project.use.baseURL; + if (typeof configuredBaseUrl !== 'string') { + throw new Error('Playwright baseURL is required for the mobile viewport contract'); + } + + const deviceWidths = [360, 390, 480]; + const measurements: Record = {}; + + for (const deviceWidth of deviceWidths) { + const context = await browser.newContext({ + baseURL: configuredBaseUrl, + viewport: { width: deviceWidth, height: 844 }, + screen: { width: deviceWidth, height: 844 }, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + colorScheme: 'dark', + }); + const mobilePage = await context.newPage(); + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 6, + npcMode: 1, + generalMeCalls: 0, + operations: [], + }; + await installFixture(mobilePage, state); + await waitForMain(mobilePage); + + const mainGeometry = await mobilePage.locator('.main-page').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + viewportMeta: document.querySelector('meta[name="viewport"]')?.content, + screenWidth: screen.availWidth, + innerWidth: window.innerWidth, + layoutViewportWidth: document.documentElement.clientWidth, + visualViewportWidth: window.visualViewport?.width ?? null, + visualViewportScale: window.visualViewport?.scale ?? null, + documentScrollWidth: document.documentElement.scrollWidth, + canvas: { + left: rect.left, + right: rect.right, + width: rect.width, + }, + }; + }); + + expect(mainGeometry.viewportMeta).toBe('width=500'); + expect(mainGeometry.screenWidth).toBe(deviceWidth); + expect(mainGeometry.layoutViewportWidth).toBe(500); + expect(mainGeometry.visualViewportWidth).toBeCloseTo(500, 2); + expect(mainGeometry.visualViewportScale).toBeCloseTo(deviceWidth / 500, 2); + expect(mainGeometry.documentScrollWidth).toBeLessThanOrEqual(mainGeometry.innerWidth); + expect(mainGeometry.canvas).toEqual({ left: 0, right: 500, width: 500 }); + expect(mainGeometry.canvas.right).toBeLessThanOrEqual((mainGeometry.visualViewportWidth ?? 0) + 0.01); + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await mobilePage.screenshot({ + path: resolve(artifactRoot, `initial-mobile-fit-${deviceWidth}.png`), + fullPage: true, + }); + } + + const routeGeometry: Record = {}; + if (deviceWidth === 390) { + for (const target of [ + 'chief-center', + 'battle-center', + 'inherit', + 'nation-betting', + ]) { + await mobilePage.goto(target); + await expect + .poll(() => mobilePage.locator('#app').evaluate((element) => getComputedStyle(element).minWidth)) + .toBe('500px'); + routeGeometry[target] = await mobilePage.locator('#app').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + viewportMeta: document.querySelector('meta[name="viewport"]')?.content, + layoutViewportWidth: document.documentElement.clientWidth, + visualViewportWidth: window.visualViewport?.width ?? null, + left: rect.left, + right: rect.right, + width: rect.width, + }; + }); + const geometry = routeGeometry[target] as { + viewportMeta: string; + layoutViewportWidth: number; + visualViewportWidth: number; + left: number; + right: number; + width: number; + }; + expect(geometry.viewportMeta).toBe('width=500'); + expect(geometry.layoutViewportWidth).toBe(500); + expect(geometry.visualViewportWidth).toBeCloseTo(500, 2); + expect(geometry.left).toBeCloseTo(0, 2); + expect(geometry.right).toBeCloseTo(500, 2); + expect(geometry.width).toBeCloseTo(500, 2); + } + } + + measurements[String(deviceWidth)] = { main: mainGeometry, routes: routeGeometry }; + await context.close(); + } + + if (artifactRoot) { + await writeFile( + resolve(artifactRoot, 'initial-mobile-fit-computed-dom.json'), + `${JSON.stringify(measurements, null, 2)}\n` + ); + } +}); + test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => { const state: NavigationFixture = { officerLevel: 1, diff --git a/app/game-frontend/index.html b/app/game-frontend/index.html index 7e6e4af1..45033591 100644 --- a/app/game-frontend/index.html +++ b/app/game-frontend/index.html @@ -2,7 +2,7 @@ - + Sammo HiDCHe - Game From 71668400dd1024d6fb6eae27feb62ff8e1c3930c Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 13 Aug 2026 15:22:40 +0000 Subject: [PATCH 3/6] fix(frontend): clarify main scenario status --- app/game-frontend/e2e/mainNavigation.spec.ts | 73 ++++++++++++++++++- .../src/components/main/MainFrontStatus.vue | 44 ++++++++--- .../src/utils/tournamentStatus.ts | 15 ++++ app/game-frontend/src/views/MainView.vue | 10 +-- .../src/views/TournamentView.vue | 17 +---- .../test/tournamentStatus.test.ts | 17 +++++ .../main-front-status.spec.ts | 10 ++- 7 files changed, 150 insertions(+), 36 deletions(-) create mode 100644 app/game-frontend/src/utils/tournamentStatus.ts create mode 100644 app/game-frontend/test/tournamentStatus.test.ts diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 0a2e49e3..d722f64b 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -34,6 +34,8 @@ type NavigationFixture = { largeCommandTable?: boolean; currentYear?: number; currentMonth?: number; + scenarioTitle?: string; + latestVote?: { id: number; title: string; hasVoted: boolean } | null; globalRecords?: Array<{ id: number; text: string }>; generalRecords?: Array<{ id: number; text: string }>; worldHistory?: Array<{ id: number; text: string }>; @@ -309,6 +311,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => { year: state.currentYear ?? 185, month: state.currentMonth ?? 1, turnTerm: 10, + scenarioTitle: state.scenarioTitle ?? '', }); } if (operation === 'dashboard.getContextBundleDelta') { @@ -421,7 +424,10 @@ const installFixture = async (page: Page, state: NavigationFixture) => { onlineGenerals: '메뉴검증장수', nationNotice: '

국가 방침

', lastExecuted: null, - latestVote: { id: 9, title: '메뉴 설문', hasVoted: false }, + latestVote: + state.latestVote === undefined + ? { id: 9, title: '메뉴 설문', hasVoted: false } + : state.latestVote, }); } if (operation === 'board.getAccess') { @@ -576,6 +582,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro nationLevel: 3, stage: 1, npcMode: 1, + scenarioTitle: '메인 화면 검증 시나리오', generalMeCalls: 0, operations: [], }; @@ -589,6 +596,45 @@ 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: '메인 화면 검증 시나리오', exact: true })).toHaveCount(1); + await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분'); + await expect(page.locator('.game-shell__subtitle')).not.toContainText('메인 화면 검증 시나리오'); + await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중'); + await expect(page.locator('.vote-status')).toHaveText('설문: 메뉴 설문'); + const headerStatusGeometry = await page.locator('.main-page').evaluate((element) => { + const title = element.querySelector('.game-shell__title'); + const subtitle = element.querySelector('.game-shell__subtitle'); + const activity = element.querySelector('.activity-status'); + const tournament = element.querySelector('.tournament-status'); + const survey = element.querySelector('.vote-status'); + if (!title || !subtitle || !activity || !tournament || !survey) { + throw new Error('main header status geometry is incomplete'); + } + return { + title: title.getBoundingClientRect().toJSON(), + subtitle: subtitle.getBoundingClientRect().toJSON(), + activity: activity.getBoundingClientRect().toJSON(), + tournament: tournament.getBoundingClientRect().toJSON(), + survey: survey.getBoundingClientRect().toJSON(), + activityColumns: getComputedStyle(activity).gridTemplateColumns, + }; + }); + expect(headerStatusGeometry.subtitle.y).toBeGreaterThanOrEqual(headerStatusGeometry.title.bottom); + expect(headerStatusGeometry.activity.width).toBeCloseTo(666.67, 0); + expect(headerStatusGeometry.tournament.width).toBeCloseTo(333.33, 0); + expect(headerStatusGeometry.survey.width).toBeCloseTo(333.33, 0); + expect(headerStatusGeometry.activityColumns.split(' ')).toHaveLength(2); + const tournamentStatusLink = page.locator('.tournament-status a'); + const surveyStatusLink = page.locator('.vote-status a'); + await tournamentStatusLink.hover(); + await expect + .poll(() => tournamentStatusLink.evaluate((element) => getComputedStyle(element).cursor)) + .toBe('pointer'); + await surveyStatusLink.focus(); + await expect(surveyStatusLink).toBeFocused(); + await expect + .poll(() => surveyStatusLink.evaluate((element) => getComputedStyle(element).textDecorationLine)) + .toContain('underline'); const contentOrder = await page .locator('.record-zone, [data-menu-position="middle"], .desktop-message-panel, [data-menu-position="bottom"]') .evaluateAll((elements) => @@ -642,7 +688,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await expect(gameInfoButton).toBeFocused(); await gameInfoButton.click(); - await page.getByRole('heading', { name: '전장 현황' }).click(); + await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click(); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false'); await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); @@ -1121,6 +1167,8 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy nationLevel: 3, stage: 6, npcMode: 1, + scenarioTitle: '모바일 검증 시나리오', + latestVote: null, generalMeCalls: 0, operations: [], }; @@ -1139,6 +1187,27 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy await expect(page.locator('.main-mobile-bottom')).toBeVisible(); await page.setViewportSize({ width: 500, height: 900 }); + await expect(page.getByRole('heading', { name: '모바일 검증 시나리오', exact: true })).toHaveCount(1); + await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분'); + await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 베팅 진행중'); + await expect(page.locator('.vote-status')).toHaveText('설문: 진행 중인 설문 없음'); + const activityGeometry = await page.locator('.activity-status').evaluate((element) => { + const tournament = element.querySelector('.tournament-status'); + const survey = element.querySelector('.vote-status'); + if (!tournament || !survey) throw new Error('activity status is incomplete'); + return { + width: element.getBoundingClientRect().width, + tournamentWidth: tournament.getBoundingClientRect().width, + surveyWidth: survey.getBoundingClientRect().width, + columns: getComputedStyle(element).gridTemplateColumns, + }; + }); + expect(activityGeometry).toMatchObject({ + width: 500, + tournamentWidth: 250, + surveyWidth: 250, + columns: '250px 250px', + }); await expect .poll(() => page diff --git a/app/game-frontend/src/components/main/MainFrontStatus.vue b/app/game-frontend/src/components/main/MainFrontStatus.vue index 87b53a47..5731e4e1 100644 --- a/app/game-frontend/src/components/main/MainFrontStatus.vue +++ b/app/game-frontend/src/components/main/MainFrontStatus.vue @@ -1,5 +1,9 @@