diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index d34df786..2d0796d7 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -38,6 +38,9 @@ type FixtureState = { profileLogsEmpty?: boolean; gatewayLogPollCount?: number; gatewayLogsEmpty?: boolean; + gatewayStateFailuresAfterRequest?: number; + gatewayStateFailuresRemaining?: number; + gatewayStateFailureCount?: number; capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>; profileListDelayMs?: number; profileNavigationDelayMs?: number; @@ -139,6 +142,19 @@ const installFixture = async (page: Page, state: FixtureState) => { await route.abort('failed'); return; } + if ( + names.includes('admin.releases.gatewayState') && + (state.gatewayStateFailuresRemaining ?? 0) > 0 + ) { + state.gatewayStateFailuresRemaining = (state.gatewayStateFailuresRemaining ?? 0) - 1; + state.gatewayStateFailureCount = (state.gatewayStateFailureCount ?? 0) + 1; + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: '', + }); + return; + } if (names.includes('admin.operations.logs') && !state.profileLogProgress) { await new Promise((resolve) => setTimeout(resolve, 50)); } @@ -337,6 +353,7 @@ const installFixture = async (page: Page, state: FixtureState) => { updatedAt: '2026-08-01T02:00:00.000Z', }; state.gatewayOperations = [releaseOperation]; + state.gatewayStateFailuresRemaining = state.gatewayStateFailuresAfterRequest; return response(releaseOperation); } if (name === 'admin.operations.requestRuntime') { @@ -813,7 +830,13 @@ test('scenario-only operator resets the current version without Git or Gateway c }); test('controls gateway deployment and rollback through the external controller queue', async ({ page }, testInfo) => { - const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] }; + const state: FixtureState = { + operations: [], + gatewayOperations: [], + runtimeRunning: true, + requestBodies: [], + gatewayStateFailuresAfterRequest: 1, + }; await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); @@ -826,6 +849,9 @@ test('controls gateway deployment and rollback through the external controller q await page.getByTestId('request-gateway-deploy').click(); await expect(page.getByText(/Gateway 배포 작업을 등록했습니다/).first()).toBeVisible(); + expect(state.gatewayStateFailureCount).toBe(1); + await expect(page.getByTestId('server-operations-page')).not.toContainText('Unexpected end of JSON input'); + await expect(page.getByTestId('action-toast').filter({ hasText: 'Unexpected end of JSON input' })).toHaveCount(0); await expect(page.getByTestId('gateway-release-table')).toContainText('DEPLOY'); await expect(page.getByTestId('gateway-release-log-panel')).toBeVisible(); await expect(page.getByTestId('gateway-release-log')).toContainText('Gateway 구성 요소를 빌드합니다.'); diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index 1d92247c..2a3cb699 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -112,6 +112,7 @@ let stateRequestInFlight = false; let releaseLogLoopGeneration = 0; let profileLogLoopGeneration = 0; let componentMounted = false; +let gatewayReleaseTransitionActive = false; const form = reactive({ sourceMode: (props.mode === 'scenario' ? 'CURRENT' : 'BRANCH') as 'CURRENT' | 'BRANCH' | 'COMMIT', @@ -295,6 +296,7 @@ const loadState = async (quiet = false) => { const active = gatewayReleaseOperations.value.find((operation) => ['QUEUED', 'RUNNING'].includes(operation.status) ); + gatewayReleaseTransitionActive = Boolean(active); if (active && selectedGatewayOperationId.value !== active.id) { selectedGatewayOperationId.value = active.id; } else if ( @@ -321,7 +323,11 @@ const loadState = async (quiet = false) => { } } } catch (error) { - errorMessage.value = error instanceof Error ? error.message : '운영 상태를 불러오지 못했습니다.'; + // Gateway release가 process를 교체하는 동안에는 background poll의 HTTP 연결이 + // 일시적으로 끊길 수 있다. 이 경우는 기존 상태를 유지하고 다음 poll에서 복구한다. + if (!quiet || props.mode !== 'gateway' || !gatewayReleaseTransitionActive) { + errorMessage.value = error instanceof Error ? error.message : '운영 상태를 불러오지 못했습니다.'; + } } finally { loading.value = false; stateRequestInFlight = false; @@ -484,6 +490,7 @@ const requestGatewayDeploy = async () => { sourceRef: gatewayForm.sourceRef.trim(), reason: gatewayForm.reason.trim() || undefined, }); + gatewayReleaseTransitionActive = true; selectedGatewayOperationId.value = operation.id; message.value = 'Gateway 배포 작업을 등록했습니다. 외부 release-controller가 처리합니다.'; await loadState(true); @@ -508,6 +515,7 @@ const requestGatewayRollback = async () => { const operation = await adminClient.releases.requestGatewayRollback.mutate({ reason: gatewayForm.reason.trim() || undefined, }); + gatewayReleaseTransitionActive = true; selectedGatewayOperationId.value = operation.id; message.value = 'Gateway rollback 작업을 등록했습니다.'; await loadState(true);