From 8e65dc992d02134b0a9e853a2691f4f8b01147be Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 8 Aug 2026 03:36:53 +0000 Subject: [PATCH] test: verify fresh image lifecycle --- .../e2e/fresh-accounts.playwright.config.mjs | 35 +++ .../e2e/fresh-accounts.spec.ts | 106 ++++++++ .../e2e/hwe-lifecycle.spec.ts | 61 +++-- .../live-game-lifecycle.playwright.config.mjs | 17 ++ .../e2e/live-game-lifecycle.spec.ts | 228 ++++++++++++++++++ app/gateway-frontend/package.json | 2 + pnpm-lock.yaml | 20 +- 7 files changed, 430 insertions(+), 39 deletions(-) create mode 100644 app/gateway-frontend/e2e/fresh-accounts.playwright.config.mjs create mode 100644 app/gateway-frontend/e2e/fresh-accounts.spec.ts create mode 100644 app/gateway-frontend/e2e/live-game-lifecycle.playwright.config.mjs create mode 100644 app/gateway-frontend/e2e/live-game-lifecycle.spec.ts diff --git a/app/gateway-frontend/e2e/fresh-accounts.playwright.config.mjs b/app/gateway-frontend/e2e/fresh-accounts.playwright.config.mjs new file mode 100644 index 00000000..55eaac1c --- /dev/null +++ b/app/gateway-frontend/e2e/fresh-accounts.playwright.config.mjs @@ -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, + }, +}); diff --git a/app/gateway-frontend/e2e/fresh-accounts.spec.ts b/app/gateway-frontend/e2e/fresh-accounts.spec.ts new file mode 100644 index 00000000..ccd25dff --- /dev/null +++ b/app/gateway-frontend/e2e/fresh-accounts.spec.ts @@ -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 => + (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 => { + 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, + }); +}); diff --git a/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts b/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts index fc13b42c..7d1ccf71 100644 --- a/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts +++ b/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts @@ -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, diff --git a/app/gateway-frontend/e2e/live-game-lifecycle.playwright.config.mjs b/app/gateway-frontend/e2e/live-game-lifecycle.playwright.config.mjs new file mode 100644 index 00000000..47b85612 --- /dev/null +++ b/app/gateway-frontend/e2e/live-game-lifecycle.playwright.config.mjs @@ -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'), +}); diff --git a/app/gateway-frontend/e2e/live-game-lifecycle.spec.ts b/app/gateway-frontend/e2e/live-game-lifecycle.spec.ts new file mode 100644 index 00000000..aff7a362 --- /dev/null +++ b/app/gateway-frontend/e2e/live-game-lifecycle.spec.ts @@ -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 => + (await readFile(`${requiredEnv('SAMMO_LIFECYCLE_SECRET_ROOT')}/${account}_password`, 'utf8')).trim(); + +const createGatewayClient = (session: { token?: string }) => + createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: gatewayUrl, + headers: () => (session.token ? { 'x-session-token': session.token } : {}), + }), + ], + }); + +const createGameClient = (session: { token?: string }) => + createTRPCProxyClient({ + 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(); + } +}); diff --git a/app/gateway-frontend/package.json b/app/gateway-frontend/package.json index f40e72fb..3d593d57 100644 --- a/app/gateway-frontend/package.json +++ b/app/gateway-frontend/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc7007ed..6f4c54c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: