feat: make general creation daemon-atomic
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const frontendPort = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15126);
|
||||
const apiPort = Number(process.env.PLAYWRIGHT_GAME_API_PORT ?? 15127);
|
||||
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'hwe').replace(/^\/+|\/+$/g, '')}`;
|
||||
const profileId = process.env.PLAYWRIGHT_PROFILE_ID ?? 'create_general_integration';
|
||||
const scenario = process.env.PLAYWRIGHT_SCENARIO ?? '2';
|
||||
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? `${profileId}:${scenario}`;
|
||||
const baseURL = `http://127.0.0.1:${frontendPort}${basePath}/`;
|
||||
const gameApiUrl = `http://127.0.0.1:${apiPort}/trpc`;
|
||||
const databaseUrl = process.env.JOIN_LIVE_DATABASE_URL ?? '';
|
||||
const redisUrl = process.env.JOIN_LIVE_REDIS_URL ?? '';
|
||||
const gameSecret = process.env.JOIN_LIVE_GAME_SECRET ?? '';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: ['joinGeneralLive.spec.ts'],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
reporter: [['list']],
|
||||
outputDir: resolve(repositoryRoot, 'test-results/join-general-live'),
|
||||
use: {
|
||||
baseURL,
|
||||
...devices['Desktop Chrome'],
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'Asia/Seoul',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
command: 'node app/game-api/dist/index.js',
|
||||
cwd: repositoryRoot,
|
||||
url: `http://127.0.0.1:${apiPort}/healthz`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
DATABASE_URL: databaseUrl,
|
||||
REDIS_URL: redisUrl,
|
||||
GAME_TOKEN_SECRET: gameSecret,
|
||||
GAME_API_HOST: '127.0.0.1',
|
||||
GAME_API_PORT: String(apiPort),
|
||||
PROFILE: profileId,
|
||||
SCENARIO: scenario,
|
||||
GAME_PROFILE_NAME: gameProfile,
|
||||
},
|
||||
},
|
||||
{
|
||||
command: `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${frontendPort}`,
|
||||
cwd: repositoryRoot,
|
||||
url: baseURL,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js';
|
||||
import { createTurnDaemonRuntime, seedScenarioToDatabase } from '../../game-engine/dist/index.js';
|
||||
import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js';
|
||||
|
||||
const databaseUrl = process.env.JOIN_LIVE_DATABASE_URL;
|
||||
const gameTokenSecret = process.env.JOIN_LIVE_GAME_SECRET;
|
||||
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'create_general_integration:2';
|
||||
const userId = 'join-general-live-user';
|
||||
const hasLiveFixture = Boolean(databaseUrl && gameTokenSecret);
|
||||
|
||||
const installSession = async (page: Page): Promise<void> => {
|
||||
const now = new Date();
|
||||
const gameToken = encryptGameSessionToken(
|
||||
{
|
||||
version: 1,
|
||||
profile,
|
||||
issuedAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + 3_600_000).toISOString(),
|
||||
sessionId: `join-live-${randomUUID()}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username: 'join-live-user',
|
||||
displayName: '브라우저생성',
|
||||
roles: ['user'],
|
||||
legacyMemberNo: 7_700,
|
||||
canUseGeneralPicture: false,
|
||||
},
|
||||
sanctions: {},
|
||||
identity: {
|
||||
kakaoVerified: true,
|
||||
canCreateGeneral: true,
|
||||
requiresKakaoVerification: false,
|
||||
graceEndsAt: null,
|
||||
},
|
||||
},
|
||||
gameTokenSecret!
|
||||
);
|
||||
await page.addInitScript(
|
||||
({ token, gameProfile }) => {
|
||||
window.localStorage.setItem('sammo-game-token', token);
|
||||
window.localStorage.setItem('sammo-game-profile', gameProfile);
|
||||
},
|
||||
{ token: gameToken, gameProfile: profile }
|
||||
);
|
||||
};
|
||||
|
||||
test.describe('generic general creation through live PostgreSQL, Redis, API, and Chromium', () => {
|
||||
test.skip(!hasLiveFixture, 'live join token and database are required');
|
||||
|
||||
let db: ReturnType<typeof createGamePostgresConnector>['prisma'];
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let runtime: Awaited<ReturnType<typeof createTurnDaemonRuntime>> | undefined;
|
||||
let daemonLoop: Promise<void> | undefined;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const schema = new URL(databaseUrl!).searchParams.get('schema');
|
||||
if (schema !== 'create_general_integration') {
|
||||
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
|
||||
}
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId: 2,
|
||||
databaseUrl: databaseUrl!,
|
||||
now: new Date('2099-07-30T12:00:00.000Z'),
|
||||
installOptions: {
|
||||
turnTermMinutes: 5,
|
||||
npcMode: 0,
|
||||
showImgLevel: 3,
|
||||
serverId: profile,
|
||||
season: 1,
|
||||
},
|
||||
});
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.inputEvent.deleteMany();
|
||||
runtime = await createTurnDaemonRuntime({
|
||||
profile,
|
||||
databaseUrl: databaseUrl!,
|
||||
enableDatabaseFlush: true,
|
||||
enableLeaseHeartbeat: false,
|
||||
leaseOwnerId: 'join-general-live-daemon',
|
||||
});
|
||||
daemonLoop = runtime.lifecycle.start();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (runtime) {
|
||||
await runtime.lifecycle.stop('join general live complete');
|
||||
await daemonLoop;
|
||||
await runtime.close();
|
||||
}
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
test('keeps the request id across an accepted timeout and creates exactly once', async ({ page }, testInfo) => {
|
||||
const requestIds: string[] = [];
|
||||
let injectTimeout = true;
|
||||
await installSession(page);
|
||||
await page.route('**/trpc/join.createGeneral?batch=1', async (route) => {
|
||||
const findClientRequestId = (value: unknown): string | undefined => {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
if ('clientRequestId' in value && typeof value.clientRequestId === 'string') {
|
||||
return value.clientRequestId;
|
||||
}
|
||||
for (const nested of Object.values(value)) {
|
||||
const found = findClientRequestId(nested);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const clientRequestId = findClientRequestId(route.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:
|
||||
'장수 생성 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
|
||||
code: -32008,
|
||||
data: {
|
||||
code: 'TIMEOUT',
|
||||
httpStatus: 408,
|
||||
path: 'join.createGeneral',
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('join');
|
||||
await expect(page.getByRole('heading', { name: '장수 생성/빙의' })).toBeVisible();
|
||||
const createButton = page.locator('.form-actions').getByRole('button', {
|
||||
name: '장수 생성',
|
||||
exact: true,
|
||||
});
|
||||
await expect(createButton).toBeEnabled();
|
||||
await expect(page.getByText('은둔', { exact: true })).toHaveCount(0);
|
||||
|
||||
const geometry = await page.locator('.join-page').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
width: rect.width,
|
||||
minHeight: style.minHeight,
|
||||
padding: style.padding,
|
||||
};
|
||||
});
|
||||
expect(geometry).toEqual({
|
||||
width: 1200,
|
||||
minHeight: '900px',
|
||||
padding: '24px',
|
||||
});
|
||||
await createButton.focus();
|
||||
await expect(createButton).toBeFocused();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('join-general-focused.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await createButton.click();
|
||||
await expect(page.locator('.join-error')).toContainText('같은 요청으로 다시 시도해 주세요.');
|
||||
await expect.poll(() => db.general.count({ where: { userId } })).toBe(1);
|
||||
const storedPending = await page.evaluate(() =>
|
||||
window.sessionStorage.getItem('sammo-join-create-pending-action')
|
||||
);
|
||||
expect(storedPending).toContain(requestIds[0]);
|
||||
|
||||
await createButton.click();
|
||||
await expect(page).toHaveURL(/\/hwe\/$/);
|
||||
expect(requestIds).toHaveLength(2);
|
||||
expect(requestIds[1]).toBe(requestIds[0]);
|
||||
await expect.poll(() => db.general.count({ where: { userId } })).toBe(1);
|
||||
const created = await db.general.findFirstOrThrow({ where: { userId } });
|
||||
expect(created).toMatchObject({
|
||||
name: '브라우저생성',
|
||||
picture: 'default.jpg',
|
||||
});
|
||||
const event = await db.inputEvent.findFirstOrThrow({
|
||||
where: { actorUserId: userId, eventType: 'joinCreateGeneral' },
|
||||
});
|
||||
expect(event).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });
|
||||
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-join-create-pending-action'))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2022",
|
||||
"types": ["node", "@playwright/test"]
|
||||
},
|
||||
"include": ["./joinGeneralLive.spec.ts", "./joinGeneral.live.playwright.config.mjs"]
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"test:e2e:directories": "playwright test directoryLists.spec.ts --config e2e/playwright.config.mjs",
|
||||
"test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs",
|
||||
"test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs",
|
||||
"test:e2e:join-live": "playwright test --config e2e/joinGeneral.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node -e \"console.log('test not configured')\"",
|
||||
|
||||
@@ -10,7 +10,14 @@ import { cityLevelMap, regionMap } from '../utils/nationFormat';
|
||||
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
|
||||
type JoinInput = Parameters<typeof trpc.join.createGeneral.mutate>[0];
|
||||
type PossessCandidate = Awaited<ReturnType<typeof trpc.join.listPossessCandidates.query>>[0];
|
||||
type JoinForm = Omit<JoinInput, 'inheritBonusStat'> & { inheritBonusStat: [number, number, number] };
|
||||
type JoinForm = Omit<JoinInput, 'inheritBonusStat' | 'clientRequestId'> & {
|
||||
inheritBonusStat: [number, number, number];
|
||||
};
|
||||
type PendingJoinAction = {
|
||||
ownerUserId: string;
|
||||
input: JoinForm;
|
||||
clientRequestId: string;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const session = useSessionStore();
|
||||
@@ -21,6 +28,7 @@ const submitting = ref(false);
|
||||
|
||||
const joinConfig = ref<JoinConfig | null>(null);
|
||||
const activeTab = ref<'create' | 'possess'>('create');
|
||||
const pendingJoinStorageKey = 'sammo-join-create-pending-action';
|
||||
|
||||
const form = ref<JoinForm>({
|
||||
name: '',
|
||||
@@ -32,6 +40,55 @@ const form = ref<JoinForm>({
|
||||
inheritBonusStat: [0, 0, 0],
|
||||
});
|
||||
|
||||
const readPendingJoin = (): PendingJoinAction | null => {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(pendingJoinStorageKey);
|
||||
if (!raw) return null;
|
||||
const value = JSON.parse(raw) as Partial<PendingJoinAction>;
|
||||
if (
|
||||
!value.input ||
|
||||
typeof value.input !== 'object' ||
|
||||
typeof value.ownerUserId !== 'string' ||
|
||||
typeof value.clientRequestId !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value as PendingJoinAction;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const cloneJoinInput = (): JoinForm => JSON.parse(JSON.stringify(form.value)) as JoinForm;
|
||||
|
||||
const getPendingJoin = (): PendingJoinAction => {
|
||||
const input = cloneJoinInput();
|
||||
const current = readPendingJoin();
|
||||
const ownerUserId = joinConfig.value?.user.id ?? '';
|
||||
if (current && current.ownerUserId === ownerUserId && JSON.stringify(current.input) === JSON.stringify(input)) {
|
||||
return current;
|
||||
}
|
||||
const pending: PendingJoinAction = {
|
||||
ownerUserId,
|
||||
input,
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
};
|
||||
window.sessionStorage.setItem(pendingJoinStorageKey, JSON.stringify(pending));
|
||||
return pending;
|
||||
};
|
||||
|
||||
const clearPendingJoin = (pending: PendingJoinAction): void => {
|
||||
if (readPendingJoin()?.clientRequestId === pending.clientRequestId) {
|
||||
window.sessionStorage.removeItem(pendingJoinStorageKey);
|
||||
}
|
||||
};
|
||||
|
||||
const isIndeterminateTimeout = (value: unknown): boolean => {
|
||||
if (!value || typeof value !== 'object' || !('data' in value)) return false;
|
||||
const data = value.data;
|
||||
return Boolean(data && typeof data === 'object' && 'code' in data && data.code === 'TIMEOUT');
|
||||
};
|
||||
|
||||
const npcCandidates = ref<PossessCandidate[]>([]);
|
||||
const npcLoading = ref(false);
|
||||
const npcError = ref<string | null>(null);
|
||||
@@ -199,8 +256,13 @@ const loadConfig = async () => {
|
||||
return;
|
||||
}
|
||||
joinConfig.value = config;
|
||||
form.value.name = config.user.displayName || '';
|
||||
applyBalancedStats();
|
||||
const pending = readPendingJoin();
|
||||
if (pending?.ownerUserId === config.user.id) {
|
||||
form.value = pending.input;
|
||||
} else {
|
||||
form.value.name = config.rules.allowCustomName ? config.user.displayName || '' : '무작위';
|
||||
applyBalancedStats();
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'join_config_failed';
|
||||
} finally {
|
||||
@@ -234,13 +296,21 @@ const submitJoin = async () => {
|
||||
}
|
||||
submitting.value = true;
|
||||
error.value = null;
|
||||
const pending = getPendingJoin();
|
||||
try {
|
||||
await trpc.join.createGeneral.mutate(form.value);
|
||||
await trpc.join.createGeneral.mutate({
|
||||
...pending.input,
|
||||
clientRequestId: pending.clientRequestId,
|
||||
});
|
||||
clearPendingJoin(pending);
|
||||
await session.refreshGeneralStatus();
|
||||
if (session.hasGeneral) {
|
||||
await router.push({ name: 'home' });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isIndeterminateTimeout(err)) {
|
||||
clearPendingJoin(pending);
|
||||
}
|
||||
error.value = err instanceof Error ? err.message : 'join_failed';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
@@ -315,7 +385,13 @@ onMounted(() => {
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>장수명</span>
|
||||
<input v-model="form.name" type="text" class="form-input" />
|
||||
<input
|
||||
v-if="joinConfig?.rules.allowCustomName"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="form-input"
|
||||
/>
|
||||
<span v-else>무작위</span>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>성격</span>
|
||||
|
||||
Reference in New Issue
Block a user