오류 정지 중 가입 턴을 고정하고 화면 이동 실패 복구 지원
This commit is contained in:
@@ -6639,3 +6639,124 @@ for (const width of [1200, 390]) {
|
||||
expect(state.operations.filter((op) => op === 'messages.respond')).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1280, height: 900 },
|
||||
{ name: 'mobile', width: 390, height: 844 },
|
||||
]) {
|
||||
for (const target of [
|
||||
{ id: 'finance', chunk: 'NationStratFinanView', path: '/nation/finance' },
|
||||
{ id: 'nation-cities', chunk: 'NationCitiesView', path: '/nation/cities' },
|
||||
]) {
|
||||
test(`recovers a stalled and failed ${target.id} navigation on ${viewport.name}`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(!productionBundle, 'Tests actual production dynamic import failure.');
|
||||
await page.setViewportSize(viewport);
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 12,
|
||||
permission: 4,
|
||||
nationLevel: 3,
|
||||
stage: 0,
|
||||
npcMode: 1,
|
||||
generalMeCalls: 0,
|
||||
operations: [],
|
||||
};
|
||||
await installRealtimeHarness(page);
|
||||
await installFixture(page, state);
|
||||
await page.route(`**${basePath}/api/trpc/**`, async (route) => {
|
||||
const ops = operationNames(route);
|
||||
if (!ops.some((op) => ['nation.getStratFinan', 'nation.getCityOverview'].includes(op))) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const result = ops.map((op) =>
|
||||
response(
|
||||
op === 'nation.getStratFinan'
|
||||
? {
|
||||
editable: true,
|
||||
nationMsg: '',
|
||||
scoutMsg: '',
|
||||
nationId: 1,
|
||||
officerLevel: 12,
|
||||
year: 185,
|
||||
month: 1,
|
||||
nationsList: [],
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
income: { gold: { city: 100, war: 0 }, rice: { city: 100, wall: 0 } },
|
||||
outcome: 0,
|
||||
policy: { rate: 20, bill: 100, secretLimit: 3, blockScout: false, blockWar: false },
|
||||
warSettingCnt: { remain: 5, inc: 2, max: 10 },
|
||||
}
|
||||
: {
|
||||
me: { officerLevel: 12 },
|
||||
nation: { name: '검증국', color: '#008000' },
|
||||
cities: [],
|
||||
generals: [],
|
||||
}
|
||||
)
|
||||
);
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(result.length === 1 ? result[0] : result),
|
||||
});
|
||||
});
|
||||
await page.goto('./');
|
||||
const link = page.locator(`a[data-navigation-id="${target.id}"]:visible`).first();
|
||||
await expect(link).toBeVisible();
|
||||
let release: () => void = () => {};
|
||||
const hold = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let blocked = false;
|
||||
await page.route(`**/${target.chunk}-*.js`, async (route) => {
|
||||
if (blocked) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
blocked = true;
|
||||
await hold;
|
||||
await route.abort('failed');
|
||||
});
|
||||
const pageErrors: string[] = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
await link.click();
|
||||
const notice = page.getByTestId('game-navigation-notice');
|
||||
await expect(notice).toContainText('화면을 여는 중');
|
||||
await expect(notice).toContainText('시간이 걸리고', { timeout: 12_000 });
|
||||
expect(new URL(page.url()).pathname).toBe(`${basePath}/`);
|
||||
release();
|
||||
await expect(notice).toContainText('화면을 불러오지 못했습니다');
|
||||
await expect(notice.locator('a')).toHaveAttribute('href', `${basePath}${target.path}`);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const geometry = await notice.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
position: style.position,
|
||||
color: style.color,
|
||||
background: style.backgroundColor,
|
||||
};
|
||||
});
|
||||
expect(geometry.x).toBeGreaterThanOrEqual(0);
|
||||
expect(geometry.x + geometry.width).toBeLessThanOrEqual(viewport.width);
|
||||
await page.screenshot({ path: testInfo.outputPath('navigation-failed.png') });
|
||||
await writeFile(
|
||||
testInfo.outputPath('navigation.json'),
|
||||
JSON.stringify({ geometry, pageErrors, url: page.url(), viewport })
|
||||
);
|
||||
await writeFile(testInfo.outputPath('navigation.html'), await page.content());
|
||||
await notice.locator('a').click();
|
||||
await expect(page).toHaveURL(new RegExp(`${target.path}$`));
|
||||
await expect(notice).toBeHidden();
|
||||
await expect(page.locator('main')).toContainText(target.id === 'finance' ? '내무부' : '세 력 도 시');
|
||||
expect(pageErrors).toEqual([]);
|
||||
await page.screenshot({ path: testInfo.outputPath('navigation-recovered.png') });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useClockDisplayRefresh } from './composables/useClockDisplayRefresh';
|
||||
import { RouterView } from 'vue-router';
|
||||
import GameServerConnectionNotice from './components/ui/GameServerConnectionNotice.vue';
|
||||
import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue';
|
||||
import GameNavigationNotice from './components/ui/GameNavigationNotice.vue';
|
||||
import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice';
|
||||
|
||||
useDeploymentVersionNotice();
|
||||
@@ -13,6 +14,7 @@ useClockDisplayRefresh();
|
||||
<RouterView />
|
||||
<GameServerConnectionNotice />
|
||||
<GameFeedbackLayer />
|
||||
<GameNavigationNotice />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { routeNavigation } from '../../utils/routeNavigation';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
v-if="routeNavigation.state !== 'idle'"
|
||||
class="game-navigation-notice"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="game-navigation-notice"
|
||||
>
|
||||
<span v-if="routeNavigation.state === 'loading'">화면을 여는 중입니다.</span>
|
||||
<template v-else>
|
||||
<span v-if="routeNavigation.state === 'failed'">화면을 불러오지 못했습니다.</span>
|
||||
<span v-else>화면을 여는 데 시간이 걸리고 있습니다.</span>
|
||||
<a :href="routeNavigation.href">이 페이지 다시 열기</a>
|
||||
</template>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.game-navigation-notice {
|
||||
position: fixed;
|
||||
z-index: 2050;
|
||||
bottom: max(16px, env(safe-area-inset-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 24px);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px 16px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #8a7765;
|
||||
border-radius: 6px;
|
||||
background: #251b15;
|
||||
color: #fff;
|
||||
}
|
||||
.game-navigation-notice a {
|
||||
color: #ffd59a;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { installRouteNavigation } from '../utils/routeNavigation';
|
||||
|
||||
const MainView = () => import('../views/MainView.vue');
|
||||
const PublicView = () => import('../views/PublicView.vue');
|
||||
@@ -389,6 +390,8 @@ const router = createRouter({
|
||||
routes,
|
||||
});
|
||||
|
||||
installRouteNavigation(router);
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const session = useSessionStore();
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { reactive } from 'vue';
|
||||
import type { Router } from 'vue-router';
|
||||
|
||||
export const routeNavigation = reactive({ href: '', state: 'idle' as 'idle' | 'loading' | 'slow' | 'failed' });
|
||||
|
||||
export const installRouteNavigation = (router: Router): void => {
|
||||
let pendingPath = '';
|
||||
let visibleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let slowTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const clearTimers = () => {
|
||||
clearTimeout(visibleTimer);
|
||||
clearTimeout(slowTimer);
|
||||
};
|
||||
router.beforeEach((to) => {
|
||||
clearTimers();
|
||||
pendingPath = to.fullPath;
|
||||
routeNavigation.href = router.resolve(to).href;
|
||||
routeNavigation.state = 'idle';
|
||||
visibleTimer = setTimeout(() => (routeNavigation.state = 'loading'), 350);
|
||||
slowTimer = setTimeout(() => (routeNavigation.state = 'slow'), 10_000);
|
||||
});
|
||||
router.afterEach((to) => {
|
||||
if (to.fullPath !== pendingPath) return;
|
||||
clearTimers();
|
||||
routeNavigation.state = 'idle';
|
||||
pendingPath = '';
|
||||
});
|
||||
router.onError((_error, to) => {
|
||||
if (to.fullPath !== pendingPath) return;
|
||||
clearTimers();
|
||||
// 실패한 dynamic import는 같은 탭에서 캐시된다. RouterLink 재클릭 대신
|
||||
// 원래 목적지의 문서를 새로 받아 모듈 캐시와 세션 초기화를 다시 시작한다.
|
||||
routeNavigation.state = 'failed';
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user