test: verify fresh image lifecycle
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Generated
+7
-13
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user