From 5c19d24254f7dabfb65333e2158c19a10119399f Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:33:33 +0000 Subject: [PATCH] feat: complete auction menu parity --- app/game-api/src/router/auction/index.ts | 2 +- app/game-api/test/auctionRouter.test.ts | 308 ++++++ app/game-frontend/e2e/auction.spec.ts | 371 ++++++++ app/game-frontend/e2e/playwright.config.mjs | 2 +- app/game-frontend/package.json | 1 + app/game-frontend/src/views/AuctionView.vue | 889 ++++++++++++++---- app/gateway-api/src/adminRouter.ts | 1 + .../src/lobby/profileStatusService.ts | 7 +- .../src/orchestrator/gatewayOrchestrator.ts | 39 +- .../test/orchestratorOperations.test.ts | 13 +- app/gateway-api/test/orchestratorPlan.test.ts | 22 + .../e2e/server-operations.spec.ts | 3 +- app/gateway-frontend/src/views/AdminView.vue | 4 +- .../src/views/ServerOperationsView.vue | 13 +- .../auction-ref-measure.mjs | 257 +++++ .../test/auctionFlow.test.ts | 4 + 16 files changed, 1721 insertions(+), 215 deletions(-) create mode 100644 app/game-api/test/auctionRouter.test.ts create mode 100644 app/game-frontend/e2e/auction.spec.ts create mode 100644 tools/frontend-legacy-parity/auction-ref-measure.mjs diff --git a/app/game-api/src/router/auction/index.ts b/app/game-api/src/router/auction/index.ts index 5b2f30d..8f1ac28 100644 --- a/app/game-api/src/router/auction/index.ts +++ b/app/game-api/src/router/auction/index.ts @@ -594,7 +594,7 @@ export const auctionRouter = router({ auctionId: auction.id, generalId: general.id, amount: input.amount, - tryExtendCloseDate: input.tryExtendCloseDate ?? true, + tryExtendCloseDate: input.tryExtendCloseDate ?? false, }); if (!result || result.type !== 'auctionBid') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); diff --git a/app/game-api/test/auctionRouter.test.ts b/app/game-api/test/auctionRouter.test.ts new file mode 100644 index 0000000..fb915be --- /dev/null +++ b/app/game-api/test/auctionRouter.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { GamePrisma, RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import type { TurnDaemonTransport } from '../src/daemon/transport.js'; +import { appRouter } from '../src/router.js'; + +const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ + id: 7, + userId: 'user-1', + name: '유비', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: null, + imageServer: 0, + leadership: 50, + strength: 50, + intel: 50, + injury: 0, + experience: 0, + dedication: 0, + officerLevel: 1, + gold: 10_000, + rice: 10_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: new Date('2026-07-26T00:00:00Z'), + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: {}, + penalty: {}, + createdAt: new Date('2026-07-26T00:00:00Z'), + updatedAt: new Date('2026-07-26T00:00:00Z'), + ...overrides, +}); + +const buildAuth = (userId = 'user-1'): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: `session-${userId}`, + user: { + id: userId, + username: userId, + displayName: userId, + roles: [], + }, + sanctions: {}, +}); + +const sqlText = (query: GamePrisma.Sql): string => query.strings.join(' '); + +const buildContext = (options: { + auth?: GameSessionTokenPayload | null; + general?: GeneralRow | null; + auctions?: Array>; + queryRaw?: (query: GamePrisma.Sql) => Promise; +}) => { + const auth = options.auth === undefined ? buildAuth() : options.auth; + const general = options.general === undefined ? buildGeneral() : options.general; + const requestCommand = vi.fn(async (command: { type: string }) => { + if (command.type === 'auctionOpen') { + return { + type: 'auctionOpen' as const, + ok: true as const, + auctionId: 91, + closeAt: '2026-07-27T00:00:00.000Z', + }; + } + return { + type: 'auctionBid' as const, + ok: true as const, + auctionId: 91, + closeAt: '2026-07-27T00:00:00.000Z', + }; + }); + const queryRaw = vi.fn(options.queryRaw ?? (async () => [])); + const worldState = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 3600, + config: { + const: { + auctionName: ['청룡', '백호', '주작', '현무'], + allItems: { weapon: { che_무기_12_칠성검: 1 } }, + }, + }, + meta: { hiddenSeed: 'auction-hidden-seed' }, + updatedAt: new Date('2026-07-26T00:00:00Z'), + }; + const db = { + $queryRaw: queryRaw, + general: { + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + general?.userId === where.userId ? general : null + ), + findMany: vi.fn(async ({ where }: { where: { id: { in: number[] } } }) => + where.id.in.map((id) => ({ id, name: id === 88 ? '관우' : '조조' })) + ), + }, + auction: { + findMany: vi.fn(async () => options.auctions ?? []), + findFirst: vi.fn(async () => null), + }, + worldState: { + findFirst: vi.fn(async () => worldState), + }, + inheritancePoint: { + findUnique: vi.fn(async () => ({ value: 10_000 })), + }, + logEntry: { + findMany: vi.fn(async () => []), + }, + }; + const redis = { + zAdd: vi.fn(async () => 1), + }; + const accessTokenStore = new RedisAccessTokenStore( + { + get: async () => null, + set: async () => null, + }, + 'che:default' + ); + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis: redis as unknown as RedisConnector['client'], + turnDaemon: { requestCommand } as unknown as TurnDaemonTransport, + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore, + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { context, db, queryRaw, redis, requestCommand }; +}; + +describe('auction router actor and permission boundaries', () => { + it('rejects unauthenticated auction reads', async () => { + const fixture = buildContext({ auth: null }); + + await expect(appRouter.createCaller(fixture.context).auction.getOverview()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it('rejects reads and mutations when the authenticated user owns no general', async () => { + const fixture = buildContext({ + auth: buildAuth('user-2'), + general: buildGeneral({ userId: 'user-1' }), + }); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.auction.getOverview()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + message: 'General not found.', + }); + await expect( + caller.auction.openBuyRice({ + amount: 1000, + closeTurnCnt: 3, + startBidAmount: 500, + finishBidAmount: 2000, + }) + ).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + message: 'General not found.', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it('derives the daemon actor from the session-owned general and ignores a forged generalId field', async () => { + const fixture = buildContext({ general: buildGeneral({ id: 7, userId: 'user-1' }) }); + const input = { + amount: 1000, + closeTurnCnt: 3, + startBidAmount: 500, + finishBidAmount: 2000, + generalId: 999, + }; + + await appRouter.createCaller(fixture.context).auction.openBuyRice(input); + + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'auctionOpen', + auctionType: 'BUY_RICE', + generalId: 7, + amount: 1000, + closeTurnCnt: 3, + startBidAmount: 500, + finishBidAmount: 2000, + }); + }); + + it('redacts real unique-auction identities while preserving caller markers', async () => { + const openedAt = new Date('2026-07-26T01:00:00Z'); + const fixture = buildContext({ + auctions: [ + { + id: 31, + type: 'UNIQUE_ITEM', + targetCode: 'che_무기_12_칠성검', + hostGeneralId: 7, + hostName: null, + detail: { title: '칠성검 경매', startBidAmount: 5000 }, + status: 'OPEN', + closeAt: new Date('2026-07-27T00:00:00Z'), + bids: [ + { + id: 41, + generalId: 88, + amount: 5500, + eventAt: openedAt, + }, + ], + }, + ], + }); + + const result = await appRouter.createCaller(fixture.context).auction.getOverview(); + const unique = result.uniqueAuctions[0]; + + expect(unique).toMatchObject({ + id: 31, + hostGeneralId: null, + isCallerHost: true, + highestBid: { amount: 5500, isCaller: false }, + }); + expect(unique?.hostName).not.toBe('유비'); + expect(unique?.highestBid?.bidderName).not.toBe('관우'); + expect(JSON.stringify(unique)).not.toContain('"generalId"'); + expect(JSON.stringify(unique)).not.toContain('"hostGeneralId":7'); + }); + + it('keeps the legacy default of no requested close extension for a unique bid', async () => { + const fixture = buildContext({ + queryRaw: async (query) => { + const text = sqlText(query); + if (text.includes('FROM auction') && text.includes('WHERE id =')) { + return [ + { + id: 31, + type: 'UNIQUE_ITEM', + targetCode: 'che_무기_12_칠성검', + hostGeneralId: 88, + detail: { startBidAmount: 100, isReverse: false }, + status: 'OPEN', + closeAt: new Date('2026-07-27T00:00:00Z'), + }, + ]; + } + if (text.includes('FROM auction_bid') && text.includes('general_id =')) { + return []; + } + if (text.includes('SELECT bid.auction_id')) { + return [{ auctionId: 31, generalId: 88, amount: 100 }]; + } + if (text.includes('FROM auction_bid')) { + return [{ id: 41, generalId: 88, amount: 100, meta: {} }]; + } + if (text.includes('SELECT id, target_code')) { + return [{ id: 31, targetCode: 'che_무기_12_칠성검' }]; + } + return []; + }, + }); + + await appRouter.createCaller(fixture.context).auction.bidUnique({ + auctionId: 31, + amount: 110, + }); + + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'auctionBid', + auctionId: 31, + generalId: 7, + amount: 110, + tryExtendCloseDate: false, + }); + }); +}); diff --git a/app/game-frontend/e2e/auction.spec.ts b/app/game-frontend/e2e/auction.spec.ts new file mode 100644 index 0000000..7267ece --- /dev/null +++ b/app/game-frontend/e2e/auction.spec.ts @@ -0,0 +1,371 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')]; + +type AuctionFixture = { + failResourceBid?: boolean; + resourceBidCount: number; + uniqueBidCount: number; +}; + +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string) => ({ + error: { + message, + code: -32000, + data: { code: 'BAD_REQUEST', httpStatus: 400, path }, + }, +}); + +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const readReferenceImage = async (filename: string): Promise => { + for (const imageRoot of imageRoots) { + try { + return await readFile(resolve(imageRoot, filename)); + } catch { + // The main checkout and nested feature worktrees have different parents. + } + } + throw new Error(`Reference image not found: ${filename}`); +}; + +const overview = { + resourceAuctions: [ + { + id: 1, + type: 'BUY_RICE', + targetCode: '1000', + status: 'OPEN', + hostGeneralId: 11, + hostName: '조조', + isCallerHost: false, + closeAt: '2026-07-27T02:30:00.000Z', + detail: { + title: '쌀 1000 경매', + amount: 1000, + isReverse: false, + startBidAmount: 500, + finishBidAmount: 1800, + }, + highestBid: { + amount: 750, + bidderName: '관우', + isCaller: false, + eventAt: '2026-07-26T01:00:00.000Z', + }, + }, + { + id: 2, + type: 'SELL_RICE', + targetCode: '900', + status: 'OPEN', + hostGeneralId: 7, + hostName: '유비', + isCallerHost: true, + closeAt: '2026-07-27T03:00:00.000Z', + detail: { + title: '금 900 경매', + amount: 900, + isReverse: false, + startBidAmount: 600, + finishBidAmount: 1700, + }, + highestBid: null, + }, + ], + uniqueAuctions: [ + { + id: 10, + type: 'UNIQUE_ITEM', + targetCode: 'che_무기_12_칠성검', + status: 'OPEN', + hostGeneralId: null, + hostName: '청룡', + isCallerHost: false, + closeAt: '2026-07-27T04:00:00.000Z', + detail: { + title: '칠성검 경매', + startBidAmount: 5000, + remainCloseDateExtensionCnt: 1, + availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z', + }, + highestBid: { + amount: 5500, + bidderName: '백호', + isCaller: false, + eventAt: '2026-07-26T02:00:00.000Z', + }, + }, + { + id: 9, + type: 'UNIQUE_ITEM', + targetCode: 'che_서적_15_손자병법', + status: 'FINISHED', + hostGeneralId: null, + hostName: '현무', + isCallerHost: true, + closeAt: '2026-07-25T04:00:00.000Z', + detail: { + title: '손자병법 경매', + startBidAmount: 5000, + remainCloseDateExtensionCnt: 0, + availableLatestBidCloseDate: '2026-07-25T04:30:00.000Z', + }, + highestBid: { + amount: 6000, + bidderName: '현무', + isCaller: true, + eventAt: '2026-07-25T03:00:00.000Z', + }, + }, + ], + callerAlias: '현무', + remainPoint: 9000, + recentLogs: [ + { + id: 1, + text: '●경매 1번 거래가 성사되었습니다.', + createdAt: '2026-07-25T00:00:00.000Z', + }, + ], +}; + +const uniqueDetail = { + auction: { + id: 10, + targetCode: 'che_무기_12_칠성검', + status: 'OPEN', + hostName: '청룡', + isCallerHost: false, + closeAt: '2026-07-27T04:00:00.000Z', + detail: { + title: '칠성검 경매', + startBidAmount: 5000, + remainCloseDateExtensionCnt: 1, + availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z', + }, + }, + bids: [ + { + id: 101, + amount: 5500, + bidderName: '백호', + isCaller: false, + eventAt: '2026-07-26T02:00:00.000Z', + }, + { + id: 100, + amount: 5000, + bidderName: '현무', + isCaller: true, + eventAt: '2026-07-26T01:00:00.000Z', + }, + ], + callerAlias: '현무', + remainPoint: 9000, +}; + +const installFixture = async (page: Page, state: AuctionFixture) => { + await page.addInitScript(() => { + window.localStorage.setItem('sammo-game-token', 'ga_auction_playwright'); + window.localStorage.setItem('sammo-game-profile', 'che:default'); + }); + for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) { + await page.route(`**/image/game/${filename}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await readReferenceImage(filename), + }); + }); + } + await page.route('**/che/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'lobby.info') { + return response({ myGeneral: { id: 7, name: '유비' } }); + } + if (operation === 'join.getConfig') { + return response({}); + } + if (operation === 'auction.getOverview') { + return response(overview); + } + if (operation === 'auction.getUniqueDetail') { + return response(uniqueDetail); + } + if (operation === 'auction.bidBuyRice') { + if (state.failResourceBid) { + state.failResourceBid = false; + return errorResponse(operation, '금이 부족합니다.'); + } + state.resourceBidCount += 1; + return response({ ok: true }); + } + if (operation === 'auction.bidUnique') { + state.uniqueBidCount += 1; + return response({ ok: true }); + } + if (operation === 'auction.openBuyRice' || operation === 'auction.openSellRice') { + return response({ auctionId: 20, closeAt: '2026-07-28T00:00:00.000Z' }); + } + return errorResponse(operation, `Unhandled fixture operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); +}; + +const gotoAuction = async (page: Page, suffix = 'auction') => { + const lobbyResponse = page.waitForResponse((response) => response.url().includes('/trpc/lobby.info')); + await page.goto(suffix); + await lobbyResponse; + await expect(page.locator('#container')).toBeVisible(); +}; + +test('resource auction preserves the legacy desktop structure, geometry, and interaction states', async ({ page }) => { + const state = { failResourceBid: true, resourceBidCount: 0, uniqueBidCount: 0 }; + await installFixture(page, state); + await page.setViewportSize({ width: 1000, height: 800 }); + await gotoAuction(page); + await expect(page.getByRole('heading', { name: '경매장', exact: true })).toBeVisible(); + await expect(page.getByText('쌀 구매', { exact: true })).toBeVisible(); + await expect(page.getByText('쌀 판매', { exact: true })).toBeVisible(); + await expect(page.getByText('단가', { exact: true }).first()).toBeVisible(); + + const geometry = await page.locator('#container').evaluate((container) => { + const box = (selector: string) => { + const rect = container.querySelector(selector)!.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }; + const containerRect = container.getBoundingClientRect(); + const row = container.querySelector('.resource-row')!; + const rowRect = row.getBoundingClientRect(); + const cells = [...row.children].map((cell) => cell.getBoundingClientRect().width); + const button = container.querySelector('.tab-button')!; + const buttonStyle = getComputedStyle(button); + return { + container: { x: containerRect.x, width: containerRect.width }, + topBar: box('.top-back-bar'), + row: { width: rowRect.width, height: rowRect.height }, + cells, + button: { + height: button.getBoundingClientRect().height, + borderRadius: buttonStyle.borderRadius, + cursor: buttonStyle.cursor, + fontSize: buttonStyle.fontSize, + }, + }; + }); + expect(geometry.container).toEqual({ x: 0, width: 1000 }); + expect(geometry.topBar).toMatchObject({ x: 0, width: 1000, height: 32 }); + expect(geometry.row).toEqual({ width: 1000, height: 22 }); + expect(geometry.cells[0]).toBeCloseTo(66.66, 1); + expect(geometry.cells[1]).toBeCloseTo(133.34, 1); + expect(geometry.cells[6]).toBeCloseTo(200, 1); + expect(geometry.button).toEqual({ + height: 35.5, + borderRadius: '5.25px', + cursor: 'pointer', + fontSize: '14px', + }); + await page.screenshot({ path: 'test-results/auction/resource-desktop-initial.png', fullPage: true }); + + const firstRow = page.locator('.resource-row.clickable-row').first(); + await firstRow.click(); + const bidInput = page.getByRole('spinbutton', { name: '1번 경매 입찰가' }); + await bidInput.fill('800'); + await page.getByRole('button', { name: '입찰', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('금이 부족합니다.'); + await page.screenshot({ path: 'test-results/auction/resource-desktop-error.png', fullPage: true }); + expect(state.resourceBidCount).toBe(0); + + await page.getByRole('button', { name: '입찰', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('입찰했습니다.'); + expect(state.resourceBidCount).toBe(1); + + await firstRow.hover(); + expect(await firstRow.evaluate((row) => getComputedStyle(row).cursor)).toBe('pointer'); + await page.screenshot({ path: 'test-results/auction/resource-desktop.png', fullPage: true }); +}); + +test('resource auction keeps the legacy 500px two-row grid', async ({ page }) => { + await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 }); + await page.setViewportSize({ width: 500, height: 800 }); + await gotoAuction(page); + + const geometry = await page + .locator('.resource-row') + .first() + .evaluate((row) => { + const origin = row.getBoundingClientRect(); + const relative = (selector: string) => { + const rect = row.querySelector(selector)!.getBoundingClientRect(); + return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width, height: rect.height }; + }; + return { + row: { width: origin.width, height: origin.height }, + idx: relative('.idx'), + host: relative('.host'), + amount: relative('.amount'), + close: relative('.close-date'), + }; + }); + expect(geometry.row).toEqual({ width: 500, height: 43 }); + expect(geometry.idx).toEqual({ x: 0, y: 10.5, width: 41.65625, height: 21 }); + expect(geometry.host).toEqual({ x: 41.65625, y: 0, width: 125, height: 21 }); + expect(geometry.amount).toEqual({ x: 41.65625, y: 21, width: 125, height: 21 }); + expect(geometry.close).toEqual({ x: 416.65625, y: 10.5, width: 83.34375, height: 21 }); + await page.screenshot({ path: 'test-results/auction/resource-mobile.png', fullPage: true }); +}); + +test('unique auction separates ongoing and finished lists and auto-loads the legacy detail', async ({ page }) => { + const state = { resourceBidCount: 0, uniqueBidCount: 0 }; + await installFixture(page, state); + await page.setViewportSize({ width: 1000, height: 800 }); + await gotoAuction(page, 'auction?type=unique'); + + await expect(page.getByRole('heading', { name: '유니크 경매장', exact: true })).toBeVisible(); + await expect(page.locator('.caller-alias')).toContainText('내 가명: 현무'); + await expect(page.getByRole('heading', { name: '경매 10번 상세' })).toBeVisible(); + await expect(page.getByText('최대지연', { exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: '진행중인 경매 목록' })).toBeVisible(); + await expect(page.getByRole('heading', { name: '종료된 경매 목록' })).toBeVisible(); + await expect(page.getByText('남음', { exact: true })).toBeVisible(); + await expect(page.getByText('소진', { exact: true })).toBeVisible(); + + const aliasStyle = await page.locator('.caller-alias strong').evaluate((element) => { + const style = getComputedStyle(element); + return { color: style.color, fontWeight: style.fontWeight }; + }); + expect(aliasStyle).toEqual({ color: 'rgb(0, 255, 255)', fontWeight: '700' }); + + const input = page.getByRole('spinbutton', { name: '유산포인트' }); + await input.fill('5600'); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('칠성검 경매에 5600유산포인트를 입찰하시겠습니까?'); + await dialog.accept(); + }); + await page.getByRole('button', { name: '입찰', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('입찰이 완료되었습니다.'); + expect(state.uniqueBidCount).toBe(1); + await page.screenshot({ path: 'test-results/auction/unique-desktop.png', fullPage: true }); +}); + +test('resource host cannot bid on the auction opened by its own general', async ({ page }) => { + await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 }); + await gotoAuction(page); + + await page.locator('.resource-row.clickable-row').filter({ hasText: '유비' }).click(); + await expect(page.getByRole('button', { name: '입찰', exact: true })).toBeDisabled(); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index ddee57a..16d70d0 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -8,7 +8,7 @@ const baseURL = `http://127.0.0.1:${port}/che/`; export default defineConfig({ testDir: '.', - testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts'], + testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts', 'auction.spec.ts'], fullyParallel: false, workers: 1, timeout: 30_000, diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 48884db..7f55886 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -10,6 +10,7 @@ "test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs", "test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs", "test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "node -e \"console.log('test not configured')\"", diff --git a/app/game-frontend/src/views/AuctionView.vue b/app/game-frontend/src/views/AuctionView.vue index ca80e55..7bcf928 100644 --- a/app/game-frontend/src/views/AuctionView.vue +++ b/app/game-frontend/src/views/AuctionView.vue @@ -1,8 +1,7 @@ diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index e469052..c35cdcf 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -817,6 +817,7 @@ export const adminRouter = router({ profileName: profile.profileName, apiRunning: false, daemonRunning: false, + auctionRunning: false, tournamentRunning: false, }, })); diff --git a/app/gateway-api/src/lobby/profileStatusService.ts b/app/gateway-api/src/lobby/profileStatusService.ts index b65c2f5..362d060 100644 --- a/app/gateway-api/src/lobby/profileStatusService.ts +++ b/app/gateway-api/src/lobby/profileStatusService.ts @@ -26,6 +26,7 @@ export type LobbyProfileStatus = { runtime: { apiRunning: boolean; daemonRunning: boolean; + auctionRunning: boolean; tournamentRunning: boolean; }; korName: string; @@ -69,7 +70,10 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi private mapProfile( row: GatewayProfileRecord, - runtimeMap: Map + runtimeMap: Map< + string, + { apiRunning: boolean; daemonRunning: boolean; auctionRunning: boolean; tournamentRunning: boolean } + > ): LobbyProfileStatus { const meta = row.meta; return { @@ -81,6 +85,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi runtime: runtimeMap.get(row.profileName) ?? { apiRunning: false, daemonRunning: false, + auctionRunning: false, tournamentRunning: false, }, korName: (meta.korName as string | undefined) ?? row.profile, diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 273c083..7e90eed 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -39,6 +39,7 @@ export interface GatewayOrchestratorOptions { export interface ProfileRuntimeState { apiRunning: boolean; daemonRunning: boolean; + auctionRunning: boolean; tournamentRunning: boolean; } @@ -66,13 +67,18 @@ export const planProfileReconcile = ( ): { shouldStart: boolean; shouldStop: boolean } => { if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') { return { - shouldStart: !(runtime.apiRunning && runtime.daemonRunning && runtime.tournamentRunning), + shouldStart: !( + runtime.apiRunning && + runtime.daemonRunning && + runtime.auctionRunning && + runtime.tournamentRunning + ), shouldStop: false, }; } return { shouldStart: false, - shouldStop: runtime.apiRunning || runtime.daemonRunning || runtime.tournamentRunning, + shouldStop: runtime.apiRunning || runtime.daemonRunning || runtime.auctionRunning || runtime.tournamentRunning, }; }; @@ -273,8 +279,16 @@ const parseInstallOptions = ( }; }; -const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'tournament'): string => - `sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`; +const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'auction' | 'tournament'): string => + `sammo:${profileName}:${ + role === 'api' + ? 'game-api' + : role === 'daemon' + ? 'turn-daemon' + : role === 'auction' + ? 'auction-worker' + : 'tournament-worker' + }`; const isMissingProcessError = (error: unknown): boolean => error instanceof Error && /process or namespace not found/i.test(error.message); @@ -285,11 +299,13 @@ export const buildProcessDefinitions = ( ): { api: { name: string; script: string; cwd: string; env: Record }; daemon: { name: string; script: string; cwd: string; env: Record }; + auction: { name: string; script: string; cwd: string; env: Record }; tournament: { name: string; script: string; cwd: string; env: Record }; } => { const baseEnv = { ...(config.baseEnv ?? {}) }; const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); + const auctionName = buildProcessName(profile.profileName, 'auction'); const tournamentName = buildProcessName(profile.profileName, 'tournament'); const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot; const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api'); @@ -327,6 +343,15 @@ export const buildProcessDefinitions = ( cwd: daemonCwd, env: daemonEnv, }, + auction: { + name: auctionName, + script: apiScript, + cwd: apiCwd, + env: { + ...apiEnv, + GAME_API_ROLE: 'auction-worker', + }, + }, tournament: { name: tournamentName, script: apiScript, @@ -376,11 +401,13 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map { const apiName = buildProcessName(profileName, 'api'); const daemonName = buildProcessName(profileName, 'daemon'); + const auctionName = buildProcessName(profileName, 'auction'); const tournamentName = buildProcessName(profileName, 'tournament'); return { profileName, apiRunning: processNames.get(apiName) ?? false, daemonRunning: processNames.get(daemonName) ?? false, + auctionRunning: processNames.get(auctionName) ?? false, tournamentRunning: processNames.get(tournamentName) ?? false, }; }); @@ -981,6 +1008,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { try { await this.processManager.start(definitions.api); await this.processManager.start(definitions.daemon); + await this.processManager.start(definitions.auction); await this.processManager.start(definitions.tournament); await this.repository.updateLastError(profile.profileName, null); return true; @@ -996,10 +1024,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private async stopProfile(profile: GatewayProfileRecord): Promise { const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); + const auctionName = buildProcessName(profile.profileName, 'auction'); const tournamentName = buildProcessName(profile.profileName, 'tournament'); const existingNames = new Set((await this.processManager.list()).map((process) => process.name)); const failures: string[] = []; - for (const name of [apiName, daemonName, tournamentName]) { + for (const name of [apiName, daemonName, auctionName, tournamentName]) { if (!existingNames.has(name)) { continue; } diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index 7a47e10..71a7dd8 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -88,6 +88,7 @@ const createHarness = ( ? [ { name: 'sammo:che:2:game-api', status: 'online' }, { name: 'sammo:che:2:turn-daemon', status: 'online' }, + { name: 'sammo:che:2:auction-worker', status: 'online' }, { name: 'sammo:che:2:tournament-worker', status: 'online' }, ] : [], @@ -138,7 +139,7 @@ const createHarness = ( }; describe('GatewayOrchestrator first-class operations', () => { - it('starts both profile processes and records success', async () => { + it('starts all profile processes and records success', async () => { const harness = createHarness(buildOperation('START')); await harness.orchestrator.runOperationsNow(); @@ -147,12 +148,13 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.started.map((definition) => definition.name)).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); }); - it('stops both profile processes and records success', async () => { + it('stops all profile processes and records success', async () => { const harness = createHarness(buildOperation('STOP')); await harness.orchestrator.runOperationsNow(); @@ -161,11 +163,13 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.stopped).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); @@ -190,6 +194,7 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); @@ -203,7 +208,7 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.completions).toEqual(['FAILED']); }); - it('attempts to stop both roles before reporting a partial PM2 failure', async () => { + it('attempts to stop every role before reporting a partial PM2 failure', async () => { const harness = createHarness(buildOperation('STOP'), false, true); await harness.orchestrator.runOperationsNow(); @@ -211,11 +216,13 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.stopped).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['FAILED']); diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index ae92d15..cb7323f 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -29,6 +29,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RUNNING', { apiRunning: true, daemonRunning: false, + auctionRunning: true, tournamentRunning: true, }) ).toEqual({ shouldStart: true, shouldStop: false }); @@ -39,6 +40,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('PREOPEN', { apiRunning: false, daemonRunning: false, + auctionRunning: false, tournamentRunning: false, }) ).toEqual({ shouldStart: true, shouldStop: false }); @@ -49,16 +51,29 @@ describe('planProfileReconcile', () => { planProfileReconcile('RUNNING', { apiRunning: true, daemonRunning: true, + auctionRunning: true, tournamentRunning: true, }) ).toEqual({ shouldStart: false, shouldStop: false }); }); + it('restarts a running profile when only the auction worker is missing', () => { + expect( + planProfileReconcile('RUNNING', { + apiRunning: true, + daemonRunning: true, + auctionRunning: false, + tournamentRunning: true, + }) + ).toEqual({ shouldStart: true, shouldStop: false }); + }); + it('stops processes for non-running profiles', () => { expect( planProfileReconcile('STOPPED', { apiRunning: false, daemonRunning: true, + auctionRunning: false, tournamentRunning: false, }) ).toEqual({ shouldStart: false, shouldStop: true }); @@ -69,6 +84,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RESERVED', { apiRunning: false, daemonRunning: false, + auctionRunning: false, tournamentRunning: false, }) ).toEqual({ shouldStart: false, shouldStop: false }); @@ -95,6 +111,11 @@ describe('buildProcessDefinitions', () => { }); expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine')); expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js')); + expect(definitions.auction).toMatchObject({ + cwd: path.join(buildWorkspace, 'app', 'game-api'), + script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'), + env: { GAME_API_ROLE: 'auction-worker' }, + }); expect(definitions.tournament).toMatchObject({ cwd: path.join(buildWorkspace, 'app', 'game-api'), script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'), @@ -107,6 +128,7 @@ describe('buildProcessDefinitions', () => { expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine')); + expect(definitions.auction.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); }); }); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index ba61af7..444793f 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -38,6 +38,7 @@ const profile = (runtimeRunning: boolean) => ({ profileName: 'che:2', apiRunning: runtimeRunning, daemonRunning: runtimeRunning, + auctionRunning: runtimeRunning, tournamentRunning: runtimeRunning, }, }); @@ -212,7 +213,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true }); }); -test('starts and stops both runtime roles through the operation controls', async ({ page }) => { +test('starts and stops all runtime roles through the operation controls', async ({ page }) => { const state: FixtureState = { operations: [], runtimeRunning: false, requestBodies: [] }; await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 565317c..3509c84 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -70,6 +70,7 @@ type AdminProfile = { runtime: { apiRunning: boolean; daemonRunning: boolean; + auctionRunning: boolean; tournamentRunning: boolean; }; buildCommitSha?: string; @@ -1352,7 +1353,8 @@ onMounted(() => {
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} / - DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / TOURNAMENT: + DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / AUCTION: + {{ profile.runtime.auctionRunning ? 'ON' : 'OFF' }} / TOURNAMENT: {{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index 9908d48..bfe0515 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -16,7 +16,12 @@ type Profile = { buildWorkspace?: string; buildError?: string; lastError?: string; - runtime: { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean }; + runtime: { + apiRunning: boolean; + daemonRunning: boolean; + auctionRunning: boolean; + tournamentRunning: boolean; + }; }; type Scenario = { @@ -377,6 +382,12 @@ onBeforeUnmount(() => { {{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }} +
+
Auction worker
+
+ {{ selectedProfile.runtime.auctionRunning ? 'RUNNING' : 'STOPPED' }} +
+
Tournament worker
{ + const page = await context.newPage(); + await page.goto(targetRoot, { waitUntil: 'networkidle', timeout: 60_000 }); + const globalSalt = await page.locator('#global_salt').inputValue(); + const clientPasswordHash = createHash('sha512') + .update(globalSalt + password + globalSalt) + .digest('hex'); + const response = await context.request.post(new URL('api.php?path=Login/LoginByID', targetRoot).toString(), { + data: { username, password: clientPasswordHash }, + timeout: 60_000, + }); + const result = await response.json(); + if (!response.ok() || result.result !== true) { + throw new Error('Reference login failed.'); + } + if (allowGeneralCreate) { + const joinUrl = new URL('hwe/v_join.php', targetRoot).toString(); + await page.goto(joinUrl, { waitUntil: 'networkidle', timeout: 60_000 }); + if (page.url().includes('v_join.php')) { + const createButton = page.getByRole('button', { name: '장수 생성', exact: true }); + try { + await createButton.waitFor({ state: 'visible', timeout: 30_000 }); + } catch { + const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300); + throw new Error(`Reference general form did not render: ${page.url()} | ${pageText}`); + } + page.on('dialog', async (dialog) => dialog.accept()); + await createButton.click(); + await page.waitForURL((url) => !url.pathname.endsWith('/v_join.php'), { timeout: 60_000 }); + } + } + await page.close(); +}; + +const roundedRect = (rect) => ({ + x: Math.round(rect.x * 100) / 100, + y: Math.round(rect.y * 100) / 100, + width: Math.round(rect.width * 100) / 100, + height: Math.round(rect.height * 100) / 100, +}); + +const measurePage = async (page, type) => { + const diagnostics = []; + const onPageError = (error) => diagnostics.push(`pageerror: ${error.message}`); + const onConsole = (message) => { + if (message.type() === 'error') { + diagnostics.push(`console: ${message.text()}`); + } + }; + const onResponse = (response) => { + if (response.status() >= 400) { + diagnostics.push(`http ${response.status()}: ${response.url()}`); + } + }; + page.on('pageerror', onPageError); + page.on('console', onConsole); + page.on('response', onResponse); + const relative = type === 'unique' ? 'hwe/v_auction.php?type=unique' : 'hwe/v_auction.php'; + await page.goto(new URL(relative, targetRoot).toString(), { waitUntil: 'networkidle', timeout: 60_000 }); + try { + await page.locator('#container').waitFor({ state: 'visible', timeout: 30_000 }); + } catch { + const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300); + const scripts = await page.locator('script').evaluateAll((elements) => + elements.map((element) => ({ + src: element.getAttribute('src'), + type: element.getAttribute('type'), + length: element.textContent?.length ?? 0, + })) + ); + throw new Error( + `Reference auction did not render: ${page.url()} | ${await page.title()} | ${pageText} | ${JSON.stringify(scripts)} | ${diagnostics.slice(0, 8).join(' | ')}` + ); + } finally { + page.off('pageerror', onPageError); + page.off('console', onConsole); + page.off('response', onResponse); + } + await page.locator('button').first().waitFor({ state: 'visible' }); + await page.evaluate(() => document.fonts.ready); + + const measurement = await page.evaluate(() => { + const rect = (element) => { + if (!(element instanceof HTMLElement)) { + return null; + } + const value = element.getBoundingClientRect(); + return { x: value.x, y: value.y, width: value.width, height: value.height }; + }; + const style = (element) => { + if (!(element instanceof HTMLElement)) { + return null; + } + const value = getComputedStyle(element); + return { + color: value.color, + backgroundColor: value.backgroundColor, + backgroundImage: value.backgroundImage, + borderColor: value.borderColor, + borderWidth: value.borderWidth, + borderRadius: value.borderRadius, + fontFamily: value.fontFamily, + fontSize: value.fontSize, + fontWeight: value.fontWeight, + lineHeight: value.lineHeight, + padding: value.padding, + cursor: value.cursor, + }; + }; + const container = document.querySelector('#container'); + const topBar = container?.firstElementChild ?? null; + const topBarButtons = [...(topBar?.querySelectorAll('button') ?? [])].map((element) => ({ + text: element.textContent?.trim() ?? '', + rect: rect(element), + style: style(element), + })); + const firstButton = document.querySelector('button'); + const firstInput = [...document.querySelectorAll('input')].find( + (element) => element.getBoundingClientRect().width > 0 + ); + const firstAuctionRow = document.querySelector('.auctionItem'); + const firstAuctionRowChildren = [...(firstAuctionRow?.children ?? [])].map((element) => ({ + className: element.className, + rect: rect(element), + style: style(element), + })); + const firstSection = [...document.querySelectorAll('#container > div')].find((element) => + ['쌀 구매', '쌀 판매'].includes(element.textContent?.trim() ?? '') + ); + const directChildren = [...(container?.children ?? [])].slice(0, 12).map((element) => ({ + tag: element.tagName, + className: element.className, + text: element.textContent?.trim().replace(/\s+/g, ' ').slice(0, 80) ?? '', + rect: rect(element), + })); + return { + viewport: { width: window.innerWidth, height: window.innerHeight }, + document: { + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + }, + body: { rect: rect(document.body), style: style(document.body) }, + container: { rect: rect(container), style: style(container) }, + topBar: { rect: rect(topBar), style: style(topBar) }, + topBarButtons, + firstButton: { rect: rect(firstButton), style: style(firstButton) }, + firstInput: { rect: rect(firstInput), style: style(firstInput) }, + firstAuctionRow: { rect: rect(firstAuctionRow), style: style(firstAuctionRow) }, + firstAuctionRowChildren, + firstSection: { rect: rect(firstSection), style: style(firstSection) }, + directChildren, + }; + }); + + return { + ...measurement, + body: { + ...measurement.body, + rect: measurement.body.rect ? roundedRect(measurement.body.rect) : null, + }, + container: { + ...measurement.container, + rect: measurement.container.rect ? roundedRect(measurement.container.rect) : null, + }, + topBar: { + ...measurement.topBar, + rect: measurement.topBar.rect ? roundedRect(measurement.topBar.rect) : null, + }, + topBarButtons: measurement.topBarButtons.map((button) => ({ + ...button, + rect: button.rect ? roundedRect(button.rect) : null, + })), + firstButton: { + ...measurement.firstButton, + rect: measurement.firstButton.rect ? roundedRect(measurement.firstButton.rect) : null, + }, + firstInput: { + ...measurement.firstInput, + rect: measurement.firstInput.rect ? roundedRect(measurement.firstInput.rect) : null, + }, + firstAuctionRow: { + ...measurement.firstAuctionRow, + rect: measurement.firstAuctionRow.rect ? roundedRect(measurement.firstAuctionRow.rect) : null, + }, + firstAuctionRowChildren: measurement.firstAuctionRowChildren.map((child) => ({ + ...child, + rect: child.rect ? roundedRect(child.rect) : null, + })), + firstSection: { + ...measurement.firstSection, + rect: measurement.firstSection.rect ? roundedRect(measurement.firstSection.rect) : null, + }, + directChildren: measurement.directChildren.map((child) => ({ + ...child, + rect: child.rect ? roundedRect(child.rect) : null, + })), + }; +}; + +await mkdir(outputRoot, { recursive: true }); +const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); +const results = {}; +try { + for (const viewport of viewports) { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + colorScheme: 'dark', + }); + try { + await login(context); + const page = await context.newPage(); + for (const type of ['resource', 'unique']) { + results[`${viewport.name}-${type}`] = await measurePage(page, type); + await page.screenshot({ + path: resolve(outputRoot, `${viewport.name}-${type}.png`), + fullPage: true, + }); + } + } finally { + await context.close(); + } + } +} finally { + await browser.close(); +} + +const outputPath = resolve(outputRoot, 'computed-dom.json'); +await writeFile(outputPath, `${JSON.stringify(results, null, 2)}\n`); +console.log(JSON.stringify({ outputPath, views: Object.keys(results) })); diff --git a/tools/integration-tests/test/auctionFlow.test.ts b/tools/integration-tests/test/auctionFlow.test.ts index 763154e..782070c 100644 --- a/tools/integration-tests/test/auctionFlow.test.ts +++ b/tools/integration-tests/test/auctionFlow.test.ts @@ -451,6 +451,10 @@ describe('auction integration flow', () => { const initialScore = await redis.zScore(keys.timerKey, String(auction.id)); expect(Number(initialScore)).toBe(initialCloseAt.getTime()); + await expect(hostClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow( + '자신이 연 경매에 입찰할 수 없습니다.' + ); + const bidder1Client = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidder1.accessToken, });