diff --git a/app/game-frontend/e2e/npcPossession.live.playwright.config.mjs b/app/game-frontend/e2e/npcPossession.live.playwright.config.mjs index 3cf0dce..0fa5738 100644 --- a/app/game-frontend/e2e/npcPossession.live.playwright.config.mjs +++ b/app/game-frontend/e2e/npcPossession.live.playwright.config.mjs @@ -15,6 +15,7 @@ const gameApiUrl = `http://127.0.0.1:${apiPort}/trpc`; const databaseUrl = process.env.NPC_POSSESSION_LIVE_DATABASE_URL ?? ''; const redisUrl = process.env.NPC_POSSESSION_LIVE_REDIS_URL ?? ''; const gameSecret = process.env.NPC_POSSESSION_LIVE_GAME_SECRET ?? ''; +const daemonRequestTimeoutMs = Number(process.env.NPC_POSSESSION_LIVE_DAEMON_TIMEOUT_MS ?? '1000'); const databaseSchema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null; if (databaseUrl && databaseSchema !== profileId) { @@ -27,6 +28,9 @@ if (gameProfile !== expectedGameProfile) { `PLAYWRIGHT_GAME_PROFILE must match profile and scenario: ${gameProfile} != ${expectedGameProfile}` ); } +if (!Number.isSafeInteger(daemonRequestTimeoutMs) || daemonRequestTimeoutMs < 100 || daemonRequestTimeoutMs > 60_000) { + throw new Error('NPC_POSSESSION_LIVE_DAEMON_TIMEOUT_MS must be an integer from 100 to 60000'); +} export default defineConfig({ testDir: '.', @@ -64,6 +68,7 @@ export default defineConfig({ PROFILE: profileId, SCENARIO: scenario, GAME_PROFILE_NAME: gameProfile, + DAEMON_REQUEST_TIMEOUT_MS: String(daemonRequestTimeoutMs), }, }, { diff --git a/app/game-frontend/e2e/npcPossessionLive.spec.ts b/app/game-frontend/e2e/npcPossessionLive.spec.ts index 07d83b1..197cf28 100644 --- a/app/game-frontend/e2e/npcPossessionLive.spec.ts +++ b/app/game-frontend/e2e/npcPossessionLive.spec.ts @@ -61,6 +61,20 @@ test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and C let runtime: Awaited> | undefined; let daemonLoop: Promise | undefined; + const startDaemon = async (): Promise => { + if (runtime) { + return; + } + runtime = await createTurnDaemonRuntime({ + profile, + databaseUrl: databaseUrl!, + enableDatabaseFlush: true, + enableLeaseHeartbeat: false, + leaseOwnerId: 'npc-possession-live-daemon', + }); + daemonLoop = runtime.lifecycle.start(); + }; + test.beforeAll(async () => { const schema = new URL(databaseUrl!).searchParams.get('schema'); const profileId = profile.split(':', 1)[0] ?? ''; @@ -120,14 +134,6 @@ test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and C penalty: {}, })), }); - runtime = await createTurnDaemonRuntime({ - profile, - databaseUrl: databaseUrl!, - enableDatabaseFlush: true, - enableLeaseHeartbeat: false, - leaseOwnerId: 'npc-possession-live-daemon', - }); - daemonLoop = runtime.lifecycle.start(); }); test.afterAll(async () => { @@ -139,11 +145,8 @@ test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and C await closeDb?.(); }); - test('replays one completed possession after the browser receives an indeterminate timeout', async ({ - page, - }, testInfo) => { + test('retries one pending possession after the actual daemon transport timeout', async ({ page }, testInfo) => { const requestIds: string[] = []; - let injectTimeout = true; await installSession(page); await page.route('**/image/icons/**', async (route) => { await route.fulfill({ @@ -152,7 +155,10 @@ test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and C body: '', }); }); - await page.route('**/trpc/join.possessGeneral?batch=1', async (route) => { + page.on('request', (request) => { + if (!request.url().includes('/trpc/join.possessGeneral?batch=1')) { + return; + } const findClientRequestId = (value: unknown): string | undefined => { if (!value || typeof value !== 'object') return undefined; if ('clientRequestId' in value && typeof value.clientRequestId === 'string') { @@ -164,34 +170,9 @@ test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and C } return undefined; }; - const clientRequestId = findClientRequestId(route.request().postDataJSON()); + const clientRequestId = findClientRequestId(request.postDataJSON()); expect(clientRequestId).toMatch(/^[0-9a-f-]{36}$/); requestIds.push(clientRequestId!); - if (!injectTimeout) { - await route.continue(); - return; - } - injectTimeout = false; - const accepted = await route.fetch(); - expect(accepted.ok()).toBe(true); - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - error: { - message: - 'NPC 빙의 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.', - code: -32008, - data: { - code: 'TIMEOUT', - httpStatus: 408, - path: 'join.possessGeneral', - }, - }, - }, - ]), - }); }); await page.setViewportSize({ width: 1024, height: 900 }); @@ -217,19 +198,33 @@ test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and C const possessButton = page.locator('.npc-action').first(); await possessButton.click(); await expect(page.locator('.join-error')).toContainText('같은 요청으로 다시 시도해 주세요.'); - await expect.poll(() => db.general.count({ where: { userId } })).toBe(1); + expect(await db.general.count({ where: { userId } })).toBe(0); const pending = await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action')); expect(pending).toContain(requestIds[0]); + const eventRequestId = `npc-possess:${userId}:${requestIds[0]}`; + const pendingEvent = await db.inputEvent.findUniqueOrThrow({ + where: { requestId: eventRequestId }, + }); + expect(pendingEvent).toMatchObject({ + status: 'PENDING', + attempts: 0, + result: null, + error: null, + completedAt: null, + }); + await expect(db.npcSelectionToken.findUnique({ where: { ownerUserId: userId } })).resolves.not.toBeNull(); + await startDaemon(); await possessButton.click(); await expect(page).toHaveURL(/\/hwe\/$/); expect(requestIds).toHaveLength(2); expect(requestIds[1]).toBe(requestIds[0]); expect(await db.general.count({ where: { userId } })).toBe(1); + await expect(db.npcSelectionToken.findUnique({ where: { ownerUserId: userId } })).resolves.toBeNull(); expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toBeNull(); expect(dialogs).toContain('빙의에 성공했습니다.'); - const event = await db.inputEvent.findFirstOrThrow({ - where: { actorUserId: userId, eventType: 'npcPossessGeneral' }, + const event = await db.inputEvent.findUniqueOrThrow({ + where: { requestId: eventRequestId }, }); expect(event).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });