test: verify fresh image lifecycle

This commit is contained in:
2026-08-08 03:36:53 +00:00
parent ebf076a3c8
commit 8e65dc992d
7 changed files with 430 additions and 39 deletions
@@ -0,0 +1,35 @@
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)), '../../..');
export default defineConfig({
testDir: '.',
testMatch: 'fresh-accounts.spec.ts',
fullyParallel: false,
workers: 1,
timeout: 120_000,
globalTimeout: 180_000,
expect: { timeout: 15_000 },
reporter: [['list']],
outputDir: resolve(repositoryRoot, 'test-results/fresh-accounts'),
use: {
baseURL: process.env.SAMMO_LIFECYCLE_BASE_URL ?? 'http://127.0.0.1:15140',
...devices['Desktop Chrome'],
deviceScaleFactor: 1,
colorScheme: 'dark',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
ignoreHTTPSErrors: true,
actionTimeout: 15_000,
navigationTimeout: 30_000,
},
webServer: {
command: 'node e2e/prefix-proxy.mjs',
cwd: resolve(repositoryRoot, 'app/gateway-frontend'),
url: 'http://127.0.0.1:15140/gateway/',
reuseExistingServer: false,
timeout: 30_000,
},
});
@@ -0,0 +1,106 @@
import { readFile } from 'node:fs/promises';
import { expect, test, type Browser, type TestInfo } from '@playwright/test';
const SAMPLE_ICON = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAvElEQVR4nOXOMQEAIAzAsHqaFrQgn8nIwZE/zbnvZ+mAlg5o6YCWDmjpgJYOaOmAlg5o6YCWDmjpgJYOaOmAlg5o6YCWDmjpgJYOaOmAlg5o6YCWDmjpgJYOaOmAlg5o6YCWDmjpgJYOaOmAlg5o6YCWDmjpgJYOaOmAlg5o6YCWDmjpgJYOaOmAlg5o6YCWDmjpgJYOaOmAlg5o6YCWDmjpgJYOaOmAlg5o6YC2Elzh0mBKLrgAAAAASUVORK5CYII=',
'base64'
);
const requiredEnv = (name: string): string => {
const value = process.env[name]?.trim();
if (!value) throw new Error(`${name} is required`);
return value;
};
const readPassword = async (account: 'user_a' | 'user_b'): Promise<string> =>
(await readFile(`${requiredEnv('SAMMO_LIFECYCLE_SECRET_ROOT')}/${account}_password`, 'utf8')).trim();
const registerAccount = async (
browser: Browser,
testInfo: TestInfo,
account: {
username: string;
passwordFile: 'user_a' | 'user_b';
displayName: string;
uploadIcon: boolean;
}
): Promise<void> => {
const context = await browser.newContext({
ignoreHTTPSErrors: true,
colorScheme: 'dark',
viewport: { width: 1280, height: 900 },
});
const page = await context.newPage();
const password = await readPassword(account.passwordFile);
await page.goto('/gateway/signup');
await expect(page.getByRole('heading', { name: '회원가입' })).toBeVisible();
await page.getByLabel('계정명').fill(account.username);
await page.getByLabel('비밀번호', { exact: true }).fill(password);
await page.getByLabel('비밀번호 확인').fill(password);
await page.getByLabel('닉네임').fill(account.displayName);
const requiredAgreements = page.locator('.agreement-row input[type="checkbox"]');
await requiredAgreements.nth(0).check();
await requiredAgreements.nth(1).check();
await page.getByRole('button', { name: '가입', exact: true }).click();
let registeredNow = true;
try {
await expect(page).toHaveURL(/\/gateway\/lobby\?welcome=local$/, { timeout: 5_000 });
} catch {
registeredNow = false;
await expect(page.getByRole('alert')).toBeVisible();
await page.goto('/gateway/');
await page.getByLabel('계정명').fill(account.username);
await page.getByLabel('비밀번호').fill(password);
await page.getByRole('button', { name: '로그인', exact: true }).click();
await expect(page).toHaveURL(/\/gateway\/lobby$/);
}
await page.goto('/gateway/account');
await expect(page.locator('#account-table')).toContainText(account.username);
await expect(page.locator('#account-table')).toContainText(account.displayName);
if (account.uploadIcon) {
if (registeredNow) {
await page.locator('input[type="file"]').setInputFiles({
name: 'sample-user-icon.png',
mimeType: 'image/png',
buffer: SAMPLE_ICON,
});
await page.getByRole('button', { name: '아이콘 변경', exact: true }).click();
await expect(page.getByTestId('icon-server-modal')).toBeVisible({ timeout: 30_000 });
}
const storedIcon = page.locator('img[alt="전용 아이콘"]');
await expect(storedIcon).toHaveCount(1);
await expect(storedIcon).toHaveAttribute(
'src',
/^https:\/\/sam-image\.hided\.net\/icons\/users\/core2026\/[a-f0-9]{32}\.png$/
);
if (registeredNow) {
await page.getByTestId('icon-server-close').click();
}
}
await page.screenshot({
path: testInfo.outputPath(`${account.username}-account.png`),
fullPage: true,
});
await context.close();
};
test('two local users register and the first uploads a remote user icon', async ({ browser }, testInfo) => {
test.setTimeout(120_000);
await registerAccount(browser, testInfo, {
username: 'guiusera',
passwordFile: 'user_a',
displayName: 'GUI사용자A',
uploadIcon: true,
});
await registerAccount(browser, testInfo, {
username: 'guiuserb',
passwordFile: 'user_b',
displayName: 'GUI사용자B',
uploadIcon: false,
});
});
+35 -26
View File
@@ -76,41 +76,50 @@ test('admin resets and opens hwe, then two users create generals and reach main'
}, testInfo) => {
test.setTimeout(360_000);
const sourceCommit = requiredEnv('SAMMO_LIFECYCLE_SOURCE_COMMIT');
const adminUsername = process.env.SAMMO_LIFECYCLE_ADMIN_USERNAME?.trim() || 'guiadmin';
const profileKey = process.env.SAMMO_LIFECYCLE_PROFILE_KEY?.trim() || 'hwe:default';
const scenarioId = process.env.SAMMO_LIFECYCLE_SCENARIO_ID?.trim() || '2';
const skipReset = process.env.SAMMO_LIFECYCLE_SKIP_RESET === 'true';
page.on('dialog', (dialog) => dialog.accept());
await login(page, 'guiadmin', await readPassword('admin'));
await login(page, adminUsername, await readPassword('admin'));
await page.getByRole('link', { name: '관리자 페이지' }).click();
await expect(page).toHaveURL(/\/gateway\/admin$/);
await page.getByRole('link', { name: '서버 배포 · 시나리오 초기화' }).click();
await expect(page).toHaveURL(/\/gateway\/admin\/server-operations$/);
await page.getByTestId('profile-select').selectOption('hwe:2');
await page.getByTestId('source-commit').check();
await page.getByTestId('source-ref').fill(sourceCommit);
await page.getByTestId('load-scenarios').click();
await expect(page.getByText(/개 시나리오를 확인했습니다/)).toBeVisible();
await page.getByTestId('scenario-select').selectOption('2');
const latestOperation = page.getByTestId('operations-table').locator('tbody tr').first();
const previousLatestOperation = await latestOperation.textContent();
await page.getByTestId('request-reset').click();
await expect(page.getByText('초기화 작업을 시작했습니다.')).toBeVisible();
await expect
.poll(() => latestOperation.textContent(), {
timeout: 15_000,
})
.not.toBe(previousLatestOperation);
await expect(latestOperation).toContainText(sourceCommit, {
timeout: 15_000,
});
await expect(latestOperation.locator('td').nth(3)).toHaveText('SUCCEEDED', {
timeout: 300_000,
});
await page.getByTestId('profile-select').selectOption(profileKey);
const profileStatus = page.getByTestId('selected-profile-status');
if (!skipReset) {
await page.getByTestId('source-commit').check();
await page.getByTestId('source-ref').fill(sourceCommit);
await page.getByTestId('load-scenarios').click();
await expect(page.getByText(/개 시나리오를 확인했습니다/)).toBeVisible();
await page.getByTestId('scenario-select').selectOption(scenarioId);
const latestOperation = page.getByTestId('operations-table').locator('tbody tr').first();
const previousLatestOperation = await latestOperation.textContent();
await page.getByTestId('request-reset').click();
await expect(page.getByText('초기화 작업을 등록했습니다.')).toBeVisible();
await expect
.poll(() => latestOperation.textContent(), {
timeout: 15_000,
})
.not.toBe(previousLatestOperation);
await expect(latestOperation).toContainText(sourceCommit, {
timeout: 15_000,
});
await expect(latestOperation.locator('td').nth(4)).toHaveText('SUCCEEDED', {
timeout: 300_000,
});
}
await expect(profileStatus).toContainText('RUNNING', { timeout: 30_000 });
await expect(profileStatus).toContainText('SUCCEEDED');
await expect(profileStatus.locator('.text-emerald-400')).toHaveCount(2, {
timeout: 30_000,
});
for (const processLabel of ['Game frontend', 'Game API', 'Turn daemon']) {
await expect(profileStatus.locator('.rounded').filter({ hasText: processLabel })).toContainText('RUNNING', {
timeout: 30_000,
});
}
await page.screenshot({
path: testInfo.outputPath('admin-reset-running.png'),
fullPage: true,
@@ -0,0 +1,17 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from '@playwright/test';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
export default defineConfig({
testDir: '.',
testMatch: 'live-game-lifecycle.spec.ts',
fullyParallel: false,
workers: 1,
timeout: 300_000,
globalTimeout: 360_000,
reporter: [['list']],
outputDir: resolve(repositoryRoot, 'test-results/live-game-lifecycle'),
});
@@ -0,0 +1,228 @@
import { constants, publicEncrypt } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import { expect, test } from '@playwright/test';
import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api';
import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api';
import { Pool } from 'pg';
const gatewayUrl = process.env.SAMMO_LIFECYCLE_GATEWAY_URL ?? 'http://127.0.0.1:15001/gateway/api/trpc';
const gameUrl = process.env.SAMMO_LIFECYCLE_GAME_URL ?? 'http://127.0.0.1:15015/hwe/api/trpc';
const profile = process.env.SAMMO_LIFECYCLE_PROFILE_KEY?.trim() || 'hwe:default';
const sampleImage = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
const requiredEnv = (name: string): string => {
const value = process.env[name]?.trim();
if (!value) throw new Error(`${name} is required`);
return value;
};
const readPassword = async (account: 'admin' | 'user_a' | 'user_b'): Promise<string> =>
(await readFile(`${requiredEnv('SAMMO_LIFECYCLE_SECRET_ROOT')}/${account}_password`, 'utf8')).trim();
const createGatewayClient = (session: { token?: string }) =>
createTRPCProxyClient<GatewayAppRouter>({
links: [
httpBatchLink({
url: gatewayUrl,
headers: () => (session.token ? { 'x-session-token': session.token } : {}),
}),
],
});
const createGameClient = (session: { token?: string }) =>
createTRPCProxyClient<GameAppRouter>({
links: [
httpBatchLink({
url: gameUrl,
headers: () => (session.token ? { authorization: `Bearer ${session.token}` } : {}),
}),
],
});
const loginGame = async (username: string, password: string) => {
const gatewaySession: { token?: string } = {};
const gateway = createGatewayClient(gatewaySession);
const passwordKey = await gateway.auth.passwordKey.query();
const credential = {
keyId: passwordKey.keyId,
ciphertext: publicEncrypt(
{
key: passwordKey.publicKeyPem,
padding: constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256',
},
Buffer.from(password, 'utf8')
).toString('base64'),
};
const login = await gateway.auth.login.mutate({ username, credential });
gatewaySession.token = login.sessionToken;
const issued = await gateway.auth.issueGameSession.mutate({
sessionToken: login.sessionToken,
profile,
});
const gameSession: { token?: string } = {};
const game = createGameClient(gameSession);
gameSession.token = (await game.auth.exchangeGatewayToken.mutate({ gatewayToken: issued.gameToken })).accessToken;
return { gateway, game };
};
const monthOrdinal = (year: number, month: number): number => year * 12 + month - 1;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const createDatabasePool = (): Pool =>
new Pool({
host: process.env.POSTGRES_HOST ?? '127.0.0.1',
port: Number(process.env.POSTGRES_PORT ?? 5432),
database: process.env.POSTGRES_DB ?? 'sammo',
user: process.env.POSTGRES_USER ?? 'sammo',
password: requiredEnv('POSTGRES_PASSWORD'),
options: '-c search_path=hwe',
});
test('two users enlist, the engine advances three months, and only a chief uploads a nation image', async () => {
test.setTimeout(300_000);
const adminUsername = requiredEnv('SAMMO_LIFECYCLE_ADMIN_USERNAME');
const [{ game: admin }, { game: userA }, { game: userB }] = await Promise.all([
loginGame(adminUsername, await readPassword('admin')),
loginGame('guiusera', await readPassword('user_a')),
loginGame('guiuserb', await readPassword('user_b')),
]);
const db = createDatabasePool();
try {
const initialWorldResult = await db.query<{ current_year: number; current_month: number }>(
'SELECT current_year, current_month FROM world_state LIMIT 1'
);
const initialWorld = initialWorldResult.rows[0];
if (!initialWorld) throw new Error('HWE world_state is missing.');
const initialOrdinal = monthOrdinal(initialWorld.current_year, initialWorld.current_month);
const reserveEnlist = async (game: typeof userA, destNationId: number) => {
const context = await game.general.me.query();
if (!context?.general) throw new Error('User general is missing.');
if (context.general.nationId > 0) return context.general.id;
const snapshot = await game.turns.reserved.getGeneral.query({ generalId: context.general.id });
await game.turns.reserved.setGeneral.mutate({
generalId: context.general.id,
turnIndex: 0,
action: 'che_임관',
args: { destNationId },
expectedRevision: snapshot.revision,
});
return context.general.id;
};
const [generalAId, generalBId] = await Promise.all([reserveEnlist(userA, 1), reserveEnlist(userB, 1)]);
const runNextMonth = async () => {
const before = await admin.turnDaemon.status.query({ timeoutMs: 5_000 });
if (!before?.lastTurnTime) throw new Error('Turn daemon did not report the last monthly boundary.');
const beforeWorldResult = await db.query<{ current_year: number; current_month: number }>(
'SELECT current_year, current_month FROM world_state LIMIT 1'
);
const beforeWorld = beforeWorldResult.rows[0];
if (!beforeWorld) throw new Error('HWE world_state is missing before turn execution.');
const expectedOrdinal = monthOrdinal(beforeWorld.current_year, beforeWorld.current_month) + 1;
const lastBoundary = new Date(before.lastTurnTime).getTime();
const targetTime = new Date(Math.floor(lastBoundary / 3_600_000) * 3_600_000 + 3_900_000).toISOString();
await admin.turnDaemon.run.mutate({
reason: 'manual',
targetTime,
budget: { budgetMs: 90_000, maxGenerals: 10_000, catchUpCap: 1 },
});
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
const after = await admin.turnDaemon.status.query({ timeoutMs: 5_000 });
if (after?.lastError) throw new Error(`Turn daemon failed: ${after.lastError}`);
const worldResult = await db.query<{ current_year: number; current_month: number }>(
'SELECT current_year, current_month FROM world_state LIMIT 1'
);
const world = worldResult.rows[0];
if (world && monthOrdinal(world.current_year, world.current_month) >= expectedOrdinal) {
return;
}
await sleep(250);
}
throw new Error('Turn daemon did not complete the requested monthly boundary.');
};
for (let index = 0; index < 3; index += 1) {
await runNextMonth();
}
const progressedWorldResult = await db.query<{ current_year: number; current_month: number }>(
'SELECT current_year, current_month FROM world_state LIMIT 1'
);
const progressedWorld = progressedWorldResult.rows[0];
if (!progressedWorld) throw new Error('HWE world_state disappeared.');
expect(monthOrdinal(progressedWorld.current_year, progressedWorld.current_month) - initialOrdinal).toBe(3);
let enlisted = await db.query<{ id: number; nation_id: number; officer_level: number }>(
'SELECT id, nation_id, officer_level FROM general WHERE id = ANY($1::int[]) ORDER BY id',
[[generalAId, generalBId]]
);
expect(enlisted.rows).toHaveLength(2);
if (enlisted.rows.some((general) => general.nation_id === 0)) {
const candidateResult = await db.query<{ id: number }>(
`SELECT n.id
FROM nation n
LEFT JOIN general g ON g.nation_id = n.id
WHERE n.id > 0
GROUP BY n.id, n.level
HAVING COUNT(g.id) < 10
ORDER BY n.level DESC, n.id
LIMIT 1`
);
const candidate = candidateResult.rows[0];
if (!candidate) throw new Error('No nation has room for an opening-period enlistment.');
if (enlisted.rows.find((general) => general.id === generalAId)?.nation_id === 0) {
await reserveEnlist(userA, candidate.id);
}
if (enlisted.rows.find((general) => general.id === generalBId)?.nation_id === 0) {
await reserveEnlist(userB, candidate.id);
}
await runNextMonth();
enlisted = await db.query<{ id: number; nation_id: number; officer_level: number }>(
'SELECT id, nation_id, officer_level FROM general WHERE id = ANY($1::int[]) ORDER BY id',
[[generalAId, generalBId]]
);
}
expect(enlisted.rows.every((general) => general.nation_id > 0)).toBe(true);
// Appointment is fixture setup for the authorization boundary below. Enlistment and
// month advancement above are performed exclusively by the real turn daemon.
const connection = await db.connect();
try {
await connection.query('BEGIN');
await connection.query('UPDATE general SET officer_level = 5 WHERE id = $1', [generalAId]);
await connection.query('UPDATE general SET officer_level = 1 WHERE id = $1', [generalBId]);
await connection.query('COMMIT');
} catch (error) {
await connection.query('ROLLBACK');
throw error;
} finally {
connection.release();
}
await expect(userB.board.uploadImage.mutate({ dataUrl: sampleImage.toString('base64') })).rejects.toThrow(
'권한이 부족합니다. 수뇌부가 아닙니다.'
);
const uploaded = await userA.board.uploadImage.mutate({ dataUrl: sampleImage.toString('base64') });
expect(uploaded.url).toMatch(/^https:\/\/sam-image\.hided\.net\/uploads\/core2026\/[a-f0-9]{32}\.webp$/);
expect(uploaded.format).toBe('webp');
expect(uploaded.width).toBe(1);
expect(uploaded.height).toBe(1);
expect(uploaded.size).toBeGreaterThan(0);
} finally {
await db.end();
}
});
+2
View File
@@ -34,8 +34,10 @@
"@sammo-ts/infra": "workspace:*",
"@sammo-ts/logic": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
"@types/pg": "^8.16.0",
"@vitejs/plugin-vue": "^6.0.3",
"autoprefixer": "^10.4.23",
"pg": "^8.16.3",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "6.0.2",
+7 -13
View File
@@ -365,12 +365,18 @@ importers:
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.1.18(vite@7.3.0(@types/node@26.1.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
'@types/pg':
specifier: ^8.16.0
version: 8.16.0
'@vitejs/plugin-vue':
specifier: ^6.0.3
version: 6.0.3(vite@7.3.0(@types/node@26.1.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vue@3.5.26(typescript@6.0.2))
autoprefixer:
specifier: ^10.4.23
version: 10.4.23(postcss@8.5.19)
pg:
specifier: ^8.16.3
version: 8.16.3
postcss:
specifier: 8.5.19
version: 8.5.19
@@ -2238,9 +2244,6 @@ packages:
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/node@25.0.3':
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
'@types/node@26.1.1':
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
@@ -4548,9 +4551,6 @@ packages:
unconfig-core@7.5.0:
resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
@@ -6248,17 +6248,13 @@ snapshots:
'@types/mdurl@2.0.0': {}
'@types/node@25.0.3':
dependencies:
undici-types: 7.16.0
'@types/node@26.1.1':
dependencies:
undici-types: 8.3.0
'@types/pg@8.16.0':
dependencies:
'@types/node': 25.0.3
'@types/node': 26.1.1
pg-protocol: 1.10.3
pg-types: 2.2.0
@@ -8699,8 +8695,6 @@ snapshots:
'@quansync/fs': 1.0.0
quansync: 1.0.0
undici-types@7.16.0: {}
undici-types@8.3.0: {}
unist-util-is@6.0.1: