merge: 최신 main의 갱신 비용 변경을 tRPC 전송에 통합

# Conflicts:
#	app/game-api/src/server.ts
#	app/game-frontend/e2e/mainNavigation.spec.ts
#	app/game-frontend/src/utils/trpc.ts
This commit is contained in:
2026-08-17 11:22:19 +00:00
18 changed files with 643 additions and 96 deletions
+31 -3
View File
@@ -15,6 +15,8 @@ const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR;
const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
const realtimeAccessGrantHeader = 'x-sammo-realtime-access-grant';
const fixtureRealtimeAccessGrant = 'fixture-realtime-grant';
const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
@@ -69,6 +71,7 @@ type NavigationFixture = {
url: string;
body: unknown;
}>;
dashboardGrantHeaders?: Array<string | null>;
};
type JsonPatchOperation = {
@@ -400,7 +403,13 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
}
if (operation === 'dashboard.getContextBundleDelta') {
state.generalMeCalls += 1;
if (state.accessLimitAfterCalls !== undefined && state.generalMeCalls > state.accessLimitAfterCalls) {
const refreshGrant = route.request().headers()[realtimeAccessGrantHeader] ?? null;
(state.dashboardGrantHeaders ??= []).push(refreshGrant);
if (
state.accessLimitAfterCalls !== undefined &&
state.generalMeCalls > state.accessLimitAfterCalls &&
refreshGrant !== fixtureRealtimeAccessGrant
) {
return errorResponse(
operation,
'접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ' +
@@ -604,7 +613,15 @@ const installRealtimeHarness = async (page: Page) => {
configurable: true,
value: (type: string, payload: unknown) => {
TestEventSource.latest?.dispatchEvent(
new MessageEvent(type, { data: JSON.stringify({ type, ...((payload as object) ?? {}) }) })
new MessageEvent(type, {
data: JSON.stringify({
type,
...(type === 'readModelInvalidated' || type === 'messagesInvalidated'
? { refreshGrant: 'fixture-realtime-grant' }
: {}),
...((payload as object) ?? {}),
}),
})
);
},
});
@@ -2620,6 +2637,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
.toBe(true);
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u);
expect(state.dashboardGrantHeaders).toContain(null);
const operationsBeforeTournament = state.operations.length;
state.stage = 1;
@@ -2629,6 +2647,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
.toEqual(['dashboard.getContextBundleDelta', 'tournament.getState']);
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
await expect(page.locator('[data-navigation-id="tournament"]')).toHaveClass(/highlight/u);
expect(state.dashboardGrantHeaders?.at(-1)).toBe(fixtureRealtimeAccessGrant);
await page.evaluate(() => {
const general = document.querySelector('[data-main-target="general"]');
@@ -2949,7 +2968,15 @@ test('access limit stops automatic main refresh and closes realtime until a manu
.toBe(true);
const operationsBeforeLimit = state.operations.length;
await emitReadModelInvalidation(page, readModelInvalidation({ records: true, map: true }));
await page.evaluate(
(invalidation) => {
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
'readModelInvalidated',
{ invalidation, refreshGrant: 'expired-grant' }
);
},
readModelInvalidation({ records: true, map: true })
);
await expect(page.getByRole('alert')).toContainText('접속 제한중입니다.');
await expect
@@ -2972,6 +2999,7 @@ test('access limit stops automatic main refresh and closes realtime until a manu
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
)
.toBe(true);
expect(state.dashboardGrantHeaders?.at(-1)).toBeNull();
});
test('global activity, world history, and a month boundary refresh their visible main slices', async ({ page }) => {