From 4c9ed2f1515c461bd74eeb3d852e2eaa5f7c9162 Mon Sep 17 00:00:00 2001 From: nemeregiftbrown-byte Date: Thu, 22 Oct 2026 12:28:34 +0100 Subject: [PATCH 1/3] fix: connect analytics streams, clear rpc timers, and cover notification delivery (#1709, #1710, #1711) - #1709: Connect analytics explorer to documented backend stream endpoint. Update useStreams to query the configured backend API URL and documented stream-list endpoint (/v1/streams) by default. Normalize actual REST streamId instead of database UUID, map startTime and endTime without zero dates, derive status across active/paused/completed/cancelled states, and resolve token metadata with standard 7 decimals instead of silently defaulting to 18. Add unit and integration tests mounting AnalyticsPage against representative backend responses. - #1710: Clear RPC timeout timers on settled operations and probes. Store timeout handles in execute and probeAll and clear them in finally blocks upon settlement or early failure, preventing lingering timers and late AbortSignal triggers on already completed operations while preserving timeouts for hung requests. Add fake-timer unit tests. - #1711: Cover notification delivery failures and expired subscriptions. Add backend test suite for NotificationService verifying failure isolation across WebPush, Telegram, and Discord transports, removal of expired endpoints on HTTP 404/410, retention on transient failures, and suppression of push delivery when VAPID is unconfigured. --- backend/src/lib/rpc-pool.ts | 24 +- backend/tests/notification.service.test.ts | 404 ++++++++++++++++++ backend/tests/rpc-pool.test.ts | 232 ++++++++++ .../src/__tests__/stream-analytics.test.tsx | 322 +++++++++++++- frontend/src/hooks/useStreams.ts | 182 +++++++- frontend/src/utils/export-accounting.ts | 5 +- frontend/src/utils/stream-metrics.ts | 5 +- 7 files changed, 1142 insertions(+), 32 deletions(-) create mode 100644 backend/tests/notification.service.test.ts create mode 100644 backend/tests/rpc-pool.test.ts diff --git a/backend/src/lib/rpc-pool.ts b/backend/src/lib/rpc-pool.ts index 07d2ccc5..ebc08d67 100644 --- a/backend/src/lib/rpc-pool.ts +++ b/backend/src/lib/rpc-pool.ts @@ -70,13 +70,16 @@ export class RpcPool { for (const endpoint of this.availableEndpoints()) { const controller = new AbortController(); const started = Date.now(); + let timeoutHandle: ReturnType | undefined; try { const result = await Promise.race([ operation(endpoint.server, controller.signal), - new Promise((_, reject) => setTimeout(() => { - controller.abort(); - reject(new Error(`${label} timed out after ${this.timeoutMs}ms`)); - }, this.timeoutMs)), + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + controller.abort(); + reject(new Error(`${label} timed out after ${this.timeoutMs}ms`)); + }, this.timeoutMs); + }), ]); this.recordSuccess(endpoint, Date.now() - started); return result; @@ -84,6 +87,10 @@ export class RpcPool { lastError = error; this.recordFailure(endpoint, error, Date.now() - started); logger.warn(`[RpcPool] ${label} failed on ${endpoint.url}; failing over`, { error }); + } finally { + if (timeoutHandle !== undefined) { + clearTimeout(timeoutHandle); + } } } throw lastError instanceof Error ? lastError : new Error(`${label} failed on all RPC endpoints`); @@ -125,15 +132,22 @@ export class RpcPool { private async probeAll(): Promise { await Promise.all(this.endpoints.map(async (endpoint) => { + let timeoutHandle: ReturnType | undefined; try { const started = Date.now(); await Promise.race([ Promise.all([endpoint.server.getHealth(), endpoint.server.getLatestLedger()]), - new Promise((_, reject) => setTimeout(() => reject(new Error('health probe timeout')), this.timeoutMs)), + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error('health probe timeout')), this.timeoutMs); + }), ]); this.recordSuccess(endpoint, Date.now() - started); } catch (error) { this.recordFailure(endpoint, error, this.timeoutMs); + } finally { + if (timeoutHandle !== undefined) { + clearTimeout(timeoutHandle); + } } })); } diff --git a/backend/tests/notification.service.test.ts b/backend/tests/notification.service.test.ts new file mode 100644 index 00000000..b9a95315 --- /dev/null +++ b/backend/tests/notification.service.test.ts @@ -0,0 +1,404 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { prismaMock, webpushMock } = vi.hoisted(() => ({ + prismaMock: { + pushSubscription: { + findMany: vi.fn(), + upsert: vi.fn(), + deleteMany: vi.fn(), + }, + notificationChannel: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + linkCode: { + create: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + }, + webpushMock: { + setVapidDetails: vi.fn(), + sendNotification: vi.fn(), + }, +})); + +vi.mock('../src/lib/prisma.js', () => ({ + prisma: prismaMock, +})); + +vi.mock('web-push', () => ({ + default: webpushMock, +})); + +import { + NotificationService, + type StreamEvent, +} from '../src/services/notification.service.js'; + +describe('NotificationService', () => { + const fetchMock = vi.fn(); + const originalEnv = { ...process.env }; + + const sampleEvent: StreamEvent = { + type: 'STREAM_CREATED', + address: 'GUSER123', + title: 'Stream Created', + body: 'A new stream of 100 XLM was created.', + url: '/streams/1', + }; + + const samplePushSub = { + endpoint: 'https://push.example.com/endpoint/1', + p256dh: 'test-p256dh-key', + auth: 'test-auth-key', + }; + + beforeEach(() => { + vi.resetAllMocks(); + process.env = { + ...originalEnv, + VAPID_PUBLIC_KEY: 'test-vapid-public-key', + VAPID_PRIVATE_KEY: 'test-vapid-private-key', + VAPID_SUBJECT: 'mailto:test@flowfi.app', + TELEGRAM_BOT_TOKEN: 'test-telegram-token', + }; + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); + webpushMock.sendNotification.mockResolvedValue({ statusCode: 201 }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + describe('dispatch - failure isolation', () => { + it('attempts Telegram and Discord even when WebPush delivery fails', async () => { + prismaMock.pushSubscription.findMany.mockResolvedValue([samplePushSub]); + prismaMock.notificationChannel.findUnique.mockResolvedValue({ + address: 'GUSER123', + telegramChatId: '12345678', + discordWebhook: 'https://discord.com/api/webhooks/123/abc', + }); + + // Push rejects + webpushMock.sendNotification.mockRejectedValue(new Error('Push service unavailable')); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + expect(webpushMock.sendNotification).toHaveBeenCalledTimes(1); + + // Verify Telegram was still attempted + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.telegram.org/bottest-telegram-token/sendMessage', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('Stream Created'), + }), + ); + + // Verify Discord was still attempted + expect(fetchMock).toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/123/abc', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('Stream Created'), + }), + ); + }); + + it('attempts Push and Discord even when Telegram delivery fails', async () => { + prismaMock.pushSubscription.findMany.mockResolvedValue([samplePushSub]); + prismaMock.notificationChannel.findUnique.mockResolvedValue({ + address: 'GUSER123', + telegramChatId: '12345678', + discordWebhook: 'https://discord.com/api/webhooks/123/abc', + }); + + // Telegram fetch rejects + fetchMock.mockImplementation(async (url: string) => { + if (url.includes('telegram')) { + throw new Error('Telegram network error'); + } + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + expect(webpushMock.sendNotification).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/123/abc', + expect.anything(), + ); + }); + + it('attempts Push and Telegram even when Discord delivery fails', async () => { + prismaMock.pushSubscription.findMany.mockResolvedValue([samplePushSub]); + prismaMock.notificationChannel.findUnique.mockResolvedValue({ + address: 'GUSER123', + telegramChatId: '12345678', + discordWebhook: 'https://discord.com/api/webhooks/123/abc', + }); + + // Discord fetch rejects + fetchMock.mockImplementation(async (url: string) => { + if (url.includes('discord')) { + throw new Error('Discord rate limit / error'); + } + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + expect(webpushMock.sendNotification).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.telegram.org/bottest-telegram-token/sendMessage', + expect.anything(), + ); + }); + + it('delivers to remaining push subscriptions when one subscription fails', async () => { + const sub1 = { ...samplePushSub, endpoint: 'https://push.example.com/sub1' }; + const sub2 = { ...samplePushSub, endpoint: 'https://push.example.com/sub2' }; + + prismaMock.pushSubscription.findMany.mockResolvedValue([sub1, sub2]); + prismaMock.notificationChannel.findUnique.mockResolvedValue(null); + + webpushMock.sendNotification + .mockRejectedValueOnce(new Error('Sub 1 network failure')) + .mockResolvedValueOnce({ statusCode: 201 }); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + expect(webpushMock.sendNotification).toHaveBeenCalledTimes(2); + expect(webpushMock.sendNotification).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: sub1.endpoint }), + expect.any(String), + ); + expect(webpushMock.sendNotification).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: sub2.endpoint }), + expect.any(String), + ); + }); + }); + + describe('dispatch - expired subscriptions (404 and 410) and transient failures', () => { + it('deletes expired push subscription when service returns HTTP 404', async () => { + prismaMock.pushSubscription.findMany.mockResolvedValue([samplePushSub]); + prismaMock.notificationChannel.findUnique.mockResolvedValue(null); + prismaMock.pushSubscription.deleteMany.mockResolvedValue({ count: 1 }); + + const error404 = new Error('Not Found'); + (error404 as unknown as { statusCode: number }).statusCode = 404; + webpushMock.sendNotification.mockRejectedValue(error404); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + expect(prismaMock.pushSubscription.deleteMany).toHaveBeenCalledWith({ + where: { endpoint: samplePushSub.endpoint }, + }); + }); + + it('deletes expired push subscription when service returns HTTP 410 (Gone)', async () => { + prismaMock.pushSubscription.findMany.mockResolvedValue([samplePushSub]); + prismaMock.notificationChannel.findUnique.mockResolvedValue(null); + prismaMock.pushSubscription.deleteMany.mockResolvedValue({ count: 1 }); + + const error410 = new Error('Gone'); + (error410 as unknown as { statusCode: number }).statusCode = 410; + webpushMock.sendNotification.mockRejectedValue(error410); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + expect(prismaMock.pushSubscription.deleteMany).toHaveBeenCalledWith({ + where: { endpoint: samplePushSub.endpoint }, + }); + }); + + it('retains push subscription on transient failures (e.g. 500, 503, ETIMEDOUT)', async () => { + prismaMock.pushSubscription.findMany.mockResolvedValue([samplePushSub]); + prismaMock.notificationChannel.findUnique.mockResolvedValue(null); + + const error500 = new Error('Internal Server Error'); + (error500 as unknown as { statusCode: number }).statusCode = 500; + webpushMock.sendNotification.mockRejectedValue(error500); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + expect(prismaMock.pushSubscription.deleteMany).not.toHaveBeenCalled(); + }); + + it('retains push subscription on network errors without statusCode', async () => { + prismaMock.pushSubscription.findMany.mockResolvedValue([samplePushSub]); + prismaMock.notificationChannel.findUnique.mockResolvedValue(null); + + webpushMock.sendNotification.mockRejectedValue(new Error('Connection reset by peer')); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + expect(prismaMock.pushSubscription.deleteMany).not.toHaveBeenCalled(); + }); + }); + + describe('VAPID configuration handling', () => { + it('configures webpush when VAPID keys are present', () => { + new NotificationService(); + + expect(webpushMock.setVapidDetails).toHaveBeenCalledWith( + 'mailto:test@flowfi.app', + 'test-vapid-public-key', + 'test-vapid-private-key', + ); + }); + + it('prevents push delivery when VAPID configuration is absent', async () => { + delete process.env.VAPID_PUBLIC_KEY; + delete process.env.VAPID_PRIVATE_KEY; + + prismaMock.pushSubscription.findMany.mockResolvedValue([samplePushSub]); + prismaMock.notificationChannel.findUnique.mockResolvedValue({ + address: 'GUSER123', + telegramChatId: '12345678', + }); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + // Push delivery skipped due to absent VAPID configuration + expect(webpushMock.sendNotification).not.toHaveBeenCalled(); + + // Telegram delivery should still proceed + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.telegram.org/bottest-telegram-token/sendMessage', + expect.anything(), + ); + }); + }); + + describe('subscription and link management helpers', () => { + it('subscribes web push endpoint via upsert', async () => { + prismaMock.pushSubscription.upsert.mockResolvedValue({ id: 'sub-1' }); + + const service = new NotificationService(); + await service.subscribeWebPush({ + address: 'GUSER123', + endpoint: 'https://push.example.com/endpoint', + keys: { p256dh: 'p256dh-key', auth: 'auth-key' }, + userAgent: 'Mozilla/5.0', + }); + + expect(prismaMock.pushSubscription.upsert).toHaveBeenCalledWith({ + where: { endpoint: 'https://push.example.com/endpoint' }, + update: { + address: 'GUSER123', + p256dh: 'p256dh-key', + auth: 'auth-key', + userAgent: 'Mozilla/5.0', + }, + create: { + address: 'GUSER123', + endpoint: 'https://push.example.com/endpoint', + p256dh: 'p256dh-key', + auth: 'auth-key', + userAgent: 'Mozilla/5.0', + }, + }); + }); + + it('unsubscribes web push endpoint', async () => { + prismaMock.pushSubscription.deleteMany.mockResolvedValue({ count: 1 }); + + const service = new NotificationService(); + await service.unsubscribeWebPush('GUSER123', 'https://push.example.com/endpoint'); + + expect(prismaMock.pushSubscription.deleteMany).toHaveBeenCalledWith({ + where: { address: 'GUSER123', endpoint: 'https://push.example.com/endpoint' }, + }); + }); + + it('generates a valid link code', async () => { + prismaMock.linkCode.create.mockImplementation(async ({ data }) => ({ + id: 'lc-1', + ...data, + })); + + const service = new NotificationService(); + const result = await service.generateLinkCode('GUSER123'); + + expect(result).toHaveProperty('code'); + expect(result.address).toBe('GUSER123'); + expect(prismaMock.linkCode.create).toHaveBeenCalledTimes(1); + }); + + it('links telegram when code is valid', async () => { + prismaMock.linkCode.findUnique.mockResolvedValue({ + code: 'VALID1', + address: 'GUSER123', + usedAt: null, + expiresAt: new Date(Date.now() + 60_000), + }); + prismaMock.linkCode.update.mockResolvedValue({}); + prismaMock.notificationChannel.upsert.mockResolvedValue({}); + + const service = new NotificationService(); + await service.linkTelegram('VALID1', 'chat_999'); + + expect(prismaMock.linkCode.update).toHaveBeenCalledWith({ + where: { code: 'VALID1' }, + data: { usedAt: expect.any(Date) }, + }); + expect(prismaMock.notificationChannel.upsert).toHaveBeenCalledWith({ + where: { address: 'GUSER123' }, + update: { telegramChatId: 'chat_999' }, + create: { address: 'GUSER123', telegramChatId: 'chat_999' }, + }); + }); + + it('rejects linking telegram when code is invalid or expired', async () => { + prismaMock.linkCode.findUnique.mockResolvedValue({ + code: 'EXPIRED', + address: 'GUSER123', + usedAt: null, + expiresAt: new Date(Date.now() - 60_000), + }); + + const service = new NotificationService(); + await expect(service.linkTelegram('EXPIRED', 'chat_999')).rejects.toThrow( + 'Link code is invalid or expired', + ); + }); + + it('sets discord webhook for valid url', async () => { + prismaMock.notificationChannel.upsert.mockResolvedValue({}); + + const service = new NotificationService(); + await service.setDiscordWebhook( + 'GUSER123', + 'https://discord.com/api/webhooks/12345/abcdef', + ); + + expect(prismaMock.notificationChannel.upsert).toHaveBeenCalledWith({ + where: { address: 'GUSER123' }, + update: { discordWebhook: 'https://discord.com/api/webhooks/12345/abcdef' }, + create: { address: 'GUSER123', discordWebhook: 'https://discord.com/api/webhooks/12345/abcdef' }, + }); + }); + + it('rejects invalid discord webhook url', async () => { + const service = new NotificationService(); + await expect( + service.setDiscordWebhook('GUSER123', 'https://malicious.example.com/webhook'), + ).rejects.toThrow('Invalid Discord webhook URL'); + }); + }); +}); diff --git a/backend/tests/rpc-pool.test.ts b/backend/tests/rpc-pool.test.ts new file mode 100644 index 00000000..5e873cd9 --- /dev/null +++ b/backend/tests/rpc-pool.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { rpc } from '@stellar/stellar-sdk'; + +vi.mock('../src/logger.js', () => ({ + default: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { RpcPool } from '../src/lib/rpc-pool.js'; + +function createMockServer(overrides: Partial = {}) { + return { + getHealth: vi.fn().mockResolvedValue({ status: 'healthy' }), + getLatestLedger: vi.fn().mockResolvedValue({ sequence: 1000 }), + ...overrides, + } as unknown as rpc.Server; +} + +describe('RpcPool', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('execute timer management', () => { + it('clears timeout timer on successful operation and does not abort signal later', async () => { + const server1 = createMockServer(); + const pool = new RpcPool(['https://rpc1.example.com'], { + timeoutMs: 5000, + createServer: () => server1, + }); + + let capturedSignal!: AbortSignal; + const resultPromise = pool.execute('test-op', async (_server, signal) => { + capturedSignal = signal; + return 'success-value'; + }); + + const result = await resultPromise; + expect(result).toBe('success-value'); + + // Timer must be cleared immediately after settling + expect(vi.getTimerCount()).toBe(0); + + // Advance time past the original timeout; signal must not be aborted + await vi.advanceTimersByTimeAsync(10_000); + expect(capturedSignal.aborted).toBe(false); + }); + + it('clears timeout timer on early failure before timeout', async () => { + const server1 = createMockServer(); + const server2 = createMockServer(); + const servers = [server1, server2]; + let serverIndex = 0; + + const pool = new RpcPool(['https://rpc1.example.com', 'https://rpc2.example.com'], { + timeoutMs: 5000, + createServer: () => servers[serverIndex++] ?? server2, + }); + + let endpoint1Signal!: AbortSignal; + let endpoint2Signal!: AbortSignal; + let callCount = 0; + + const resultPromise = pool.execute('failover-op', async (_server, signal) => { + callCount++; + if (callCount === 1) { + endpoint1Signal = signal; + throw new Error('immediate rpc 1 network failure'); + } + endpoint2Signal = signal; + return 'recovered-value'; + }); + + const result = await resultPromise; + expect(result).toBe('recovered-value'); + expect(callCount).toBe(2); + + // Timers should be cleared + expect(vi.getTimerCount()).toBe(0); + + // Advancing past timeoutMs should not abort endpoint 1 or endpoint 2 + await vi.advanceTimersByTimeAsync(10_000); + expect(endpoint1Signal.aborted).toBe(false); + expect(endpoint2Signal.aborted).toBe(false); + }); + + it('clears timeout timer when all endpoints fail early', async () => { + const server1 = createMockServer(); + const pool = new RpcPool(['https://rpc1.example.com'], { + timeoutMs: 5000, + createServer: () => server1, + }); + + const resultPromise = pool.execute('all-fail', async () => { + throw new Error('rpc unavailable'); + }); + + await expect(resultPromise).rejects.toThrow('rpc unavailable'); + expect(vi.getTimerCount()).toBe(0); + + await vi.advanceTimersByTimeAsync(10_000); + }); + + it('preserves timeout and aborts signal for genuinely hung requests, failing over', async () => { + const server1 = createMockServer(); + const server2 = createMockServer(); + const servers = [server1, server2]; + let serverIndex = 0; + + const pool = new RpcPool(['https://rpc1.example.com', 'https://rpc2.example.com'], { + timeoutMs: 3000, + createServer: () => servers[serverIndex++] ?? server2, + }); + + let endpoint1Signal!: AbortSignal; + let callCount = 0; + + const resultPromise = pool.execute('hung-op', async (_server, signal) => { + callCount++; + if (callCount === 1) { + endpoint1Signal = signal; + // Hang indefinitely until abort signal fires + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + reject(new Error('aborted by controller')); + }); + }); + } + return 'recovered-after-hang'; + }); + + // Advance timers by 3000ms to trigger the timeout on endpoint 1 + await vi.advanceTimersByTimeAsync(3000); + + const result = await resultPromise; + expect(result).toBe('recovered-after-hang'); + expect(callCount).toBe(2); + expect(endpoint1Signal.aborted).toBe(true); + + // Post-recovery, timer count should be zero + expect(vi.getTimerCount()).toBe(0); + }); + + it('rejects with timeout when all endpoints genuinely hang', async () => { + const server1 = createMockServer(); + const pool = new RpcPool(['https://rpc1.example.com'], { + timeoutMs: 2000, + createServer: () => server1, + }); + + let capturedSignal!: AbortSignal; + const resultPromise = pool.execute('all-hang', async (_server, signal) => { + capturedSignal = signal; + return new Promise(() => undefined); + }); + + await vi.advanceTimersByTimeAsync(2000); + + await expect(resultPromise).rejects.toThrow('all-hang timed out after 2000ms'); + expect(capturedSignal.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + }); + + describe('probeAll timer management', () => { + it('clears timeout timer on successful health probe', async () => { + const server1 = createMockServer({ + getHealth: vi.fn().mockResolvedValue({ status: 'healthy' }), + getLatestLedger: vi.fn().mockResolvedValue({ sequence: 500 }), + }); + + const pool = new RpcPool(['https://rpc1.example.com'], { + timeoutMs: 4000, + createServer: () => server1, + }); + + await pool.probe(); + expect(vi.getTimerCount()).toBe(0); + + const [health] = pool.getHealth(); + expect(health?.state).toBe('CLOSED'); + expect(health?.failures).toBe(0); + }); + + it('clears timeout timer on early probe failure', async () => { + const server1 = createMockServer({ + getHealth: vi.fn().mockRejectedValue(new Error('endpoint down')), + getLatestLedger: vi.fn().mockResolvedValue({ sequence: 500 }), + }); + + const pool = new RpcPool(['https://rpc1.example.com'], { + timeoutMs: 4000, + createServer: () => server1, + }); + + await pool.probe(); + expect(vi.getTimerCount()).toBe(0); + + const [health] = pool.getHealth(); + expect(health?.failures).toBe(1); + }); + + it('handles probe timeout when probe genuinely hangs', async () => { + const server1 = createMockServer({ + getHealth: vi.fn().mockReturnValue(new Promise(() => undefined)), + getLatestLedger: vi.fn().mockReturnValue(new Promise(() => undefined)), + }); + + const pool = new RpcPool(['https://rpc1.example.com'], { + timeoutMs: 2000, + createServer: () => server1, + }); + + const probePromise = pool.probe(); + await vi.advanceTimersByTimeAsync(2000); + await probePromise; + + expect(vi.getTimerCount()).toBe(0); + const [health] = pool.getHealth(); + expect(health?.failures).toBe(1); + }); + }); +}); diff --git a/frontend/src/__tests__/stream-analytics.test.tsx b/frontend/src/__tests__/stream-analytics.test.tsx index 3db6e0e8..4832cd45 100644 --- a/frontend/src/__tests__/stream-analytics.test.tsx +++ b/frontend/src/__tests__/stream-analytics.test.tsx @@ -1,5 +1,5 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import type { Stream } from '@/types/analytics'; import { DEFAULT_FILTERS, applyFilters } from '@/utils/analytics-filters'; import { @@ -9,6 +9,17 @@ import { toCsv, } from '@/utils/export-accounting'; import { StreamAnalyticsView } from '@/components/analytics/StreamAnalyticsView'; +import { + normalizeStream, + normalizeStreams, + getDefaultStreamsEndpoint, +} from '@/hooks/useStreams'; +import { TOKEN_ADDRESSES } from '@/lib/soroban'; +import AnalyticsPage from '@/app/analytics/page'; + +vi.mock('next/navigation', () => ({ + useSearchParams: () => new URLSearchParams(), +})); const NOW = Date.parse('2025-01-15T12:00:00Z'); const NOW_SECONDS = Math.floor(NOW / 1000); @@ -274,3 +285,310 @@ describe('StreamAnalyticsView', () => { expect(onChange).toHaveBeenCalledWith({ dateRange: '7d' }); }); }); + +describe('normalizeStream and normalizeStreams (Issue #1709)', () => { + it('prefers on-chain streamId over database UUID id without silently substituting UUID', () => { + const restStream = { + id: 'a8f7c9e0-1234-5678-9abc-def012345678', + streamId: '42', + sender: 'GSENDER1111111111111111111111111111111111111111111111111111', + recipient: 'GRECIPIENT1111111111111111111111111111111111111111111111111', + tokenAddress: TOKEN_ADDRESSES.USDC, + depositedAmount: '10000000', + withdrawnAmount: '2000000', + startTime: 1710000000, + endTime: 1710086400, + isActive: true, + isPaused: false, + }; + + const normalized = normalizeStream(restStream); + expect(normalized).not.toBeNull(); + expect(normalized?.id).toBe('42'); + expect(normalized?.id).not.toBe('a8f7c9e0-1234-5678-9abc-def012345678'); + }); + + it('supports numeric streamId and snake_case stream_id', () => { + const streamNumeric = normalizeStream({ + id: 'db-uuid-1', + streamId: 77, + tokenAddress: TOKEN_ADDRESSES.XLM, + }); + expect(streamNumeric?.id).toBe('77'); + + const streamSnake = normalizeStream({ + id: 'db-uuid-2', + stream_id: '99', + tokenAddress: TOKEN_ADDRESSES.EURC, + }); + expect(streamSnake?.id).toBe('99'); + }); + + it('maps REST startTime and endTime to timestamps without zero dates', () => { + const restStream = { + id: 'uuid-1', + streamId: '10', + startTime: 1700000000, + endTime: 1700086400, + tokenAddress: TOKEN_ADDRESSES.USDC, + }; + + const normalized = normalizeStream(restStream); + expect(normalized?.startTimestamp).toBe(1700000000); + expect(normalized?.endTimestamp).toBe(1700086400); + expect(normalized?.createdAt).toBe(new Date(1700000000 * 1000).toISOString()); + }); + + it('maps REST state flags isActive and isPaused correctly across all states', () => { + // 1. Paused stream + const paused = normalizeStream({ + id: '1', + streamId: '1', + isActive: true, + isPaused: true, + tokenAddress: TOKEN_ADDRESSES.USDC, + }); + expect(paused?.status).toBe('paused'); + + // 2. Active stream + const active = normalizeStream({ + id: '2', + streamId: '2', + isActive: true, + isPaused: false, + tokenAddress: TOKEN_ADDRESSES.USDC, + }); + expect(active?.status).toBe('active'); + + // 3. Completed stream (inactive, no CANCELLED event) + const completed = normalizeStream({ + id: '3', + streamId: '3', + isActive: false, + isPaused: false, + tokenAddress: TOKEN_ADDRESSES.USDC, + events: [{ eventType: 'COMPLETED' }], + }); + expect(completed?.status).toBe('completed'); + + // 4. Cancelled stream (inactive, with CANCELLED event) + const cancelled = normalizeStream({ + id: '4', + streamId: '4', + isActive: false, + isPaused: false, + tokenAddress: TOKEN_ADDRESSES.USDC, + events: [{ eventType: 'CANCELLED' }], + }); + expect(cancelled?.status).toBe('cancelled'); + }); + + it('resolves token symbol and scales with 7 decimals instead of silently substituting 18', () => { + const usdcStream = normalizeStream({ + id: '1', + streamId: '1', + tokenAddress: TOKEN_ADDRESSES.USDC, + depositedAmount: '70000000', + }); + expect(usdcStream?.token.symbol).toBe('USDC'); + expect(usdcStream?.token.decimals).toBe(7); + expect(usdcStream?.token.decimals).not.toBe(18); + + const xlmStream = normalizeStream({ + id: '2', + streamId: '2', + tokenAddress: TOKEN_ADDRESSES.XLM, + }); + expect(xlmStream?.token.symbol).toBe('XLM'); + expect(xlmStream?.token.decimals).toBe(7); + + // Unknown contract address defaults to 7 (Stellar standard scale), never 18 + const unknownStream = normalizeStream({ + id: '3', + streamId: '3', + tokenAddress: 'CCUSTOMTOKENADDRESS1234567890', + }); + expect(unknownStream?.token.decimals).toBe(7); + expect(unknownStream?.token.decimals).not.toBe(18); + + // Explicit decimals in token record are preserved + const explicitStream = normalizeStream({ + id: '4', + streamId: '4', + token: { symbol: 'DAI', address: '0xdai', decimals: 18 }, + }); + expect(explicitStream?.token.decimals).toBe(18); + }); + + it('calculates remainingBalance from depositedAmount and withdrawnAmount when omitted', () => { + const stream = normalizeStream({ + id: '1', + streamId: '1', + tokenAddress: TOKEN_ADDRESSES.USDC, + depositedAmount: '1000000000', + withdrawnAmount: '250000000', + }); + expect(stream?.remainingBalance).toBe('750000000'); + }); + + it('normalizes wrapped array { data: [...] } and plain array [...] responses', () => { + const record = { id: 'uuid-1', streamId: '1', tokenAddress: TOKEN_ADDRESSES.USDC }; + const fromWrapped = normalizeStreams({ data: [record] }); + expect(fromWrapped).toHaveLength(1); + expect(fromWrapped[0]?.id).toBe('1'); + + const fromArray = normalizeStreams([record]); + expect(fromArray).toHaveLength(1); + expect(fromArray[0]?.id).toBe('1'); + }); +}); + +describe('getDefaultStreamsEndpoint', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('returns backend streams endpoint respecting configured NEXT_PUBLIC_API_URL', () => { + process.env.NEXT_PUBLIC_API_URL = 'http://localhost:3001/v1'; + expect(getDefaultStreamsEndpoint()).toBe('http://localhost:3001/v1/streams'); + + process.env.NEXT_PUBLIC_API_URL = 'http://localhost:3001'; + expect(getDefaultStreamsEndpoint()).toBe('http://localhost:3001/v1/streams'); + + delete process.env.NEXT_PUBLIC_API_URL; + expect(getDefaultStreamsEndpoint()).toBe('http://localhost:3001/v1/streams'); + }); +}); + +describe('Mounted AnalyticsPage against backend responses (Issue #1709)', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('mounts and renders active, paused, completed, and cancelled streams from backend response', async () => { + const backendData = { + data: [ + { + id: 'uuid-active-1', + streamId: '101', + sender: 'GSENDER1111111111111111111111111111111111111111111111111111', + recipient: 'GRECIPIENT1111111111111111111111111111111111111111111111111', + tokenAddress: TOKEN_ADDRESSES.USDC, + depositedAmount: '1000000000', + withdrawnAmount: '250000000', + startTime: NOW_SECONDS - 86400 * 5, + endTime: NOW_SECONDS + 86400 * 10, + isActive: true, + isPaused: false, + createdAt: '2025-01-10T00:00:00Z', + }, + { + id: 'uuid-paused-2', + streamId: '102', + sender: 'GSENDER2222222222222222222222222222222222222222222222222222', + recipient: 'GRECIPIENT2222222222222222222222222222222222222222222222222', + tokenAddress: TOKEN_ADDRESSES.XLM, + depositedAmount: '500000000', + withdrawnAmount: '100000000', + startTime: NOW_SECONDS - 86400 * 3, + endTime: NOW_SECONDS + 86400 * 7, + isActive: true, + isPaused: true, + createdAt: '2025-01-12T00:00:00Z', + }, + { + id: 'uuid-cancelled-3', + streamId: '103', + sender: 'GSENDER3333333333333333333333333333333333333333333333333333', + recipient: 'GRECIPIENT3333333333333333333333333333333333333333333333333', + tokenAddress: TOKEN_ADDRESSES.EURC, + depositedAmount: '300000000', + withdrawnAmount: '50000000', + startTime: NOW_SECONDS - 86400 * 8, + endTime: NOW_SECONDS + 86400 * 2, + isActive: false, + isPaused: false, + events: [{ eventType: 'CANCELLED' }], + createdAt: '2025-01-07T00:00:00Z', + }, + { + id: 'uuid-completed-4', + streamId: '104', + sender: 'GSENDER4444444444444444444444444444444444444444444444444444', + recipient: 'GRECIPIENT4444444444444444444444444444444444444444444444444', + tokenAddress: TOKEN_ADDRESSES.USDC, + depositedAmount: '200000000', + withdrawnAmount: '200000000', + startTime: NOW_SECONDS - 86400 * 20, + endTime: NOW_SECONDS - 86400 * 2, + isActive: false, + isPaused: false, + events: [{ eventType: 'COMPLETED' }], + createdAt: '2024-12-25T00:00:00Z', + }, + ], + total: 4, + }; + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => backendData, + }); + global.fetch = fetchMock; + + render(); + + // Verify endpoint called is documented backend stream list + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + expect.stringMatching(/\/v1\/streams$/), + expect.objectContaining({ headers: { Accept: 'application/json' } }), + ); + }); + + // Wait for the rows to appear in the table + await waitFor(() => { + expect(screen.getByTestId('stream-row-101')).toBeInTheDocument(); + expect(screen.getByTestId('stream-row-102')).toBeInTheDocument(); + expect(screen.getByTestId('stream-row-103')).toBeInTheDocument(); + expect(screen.getByTestId('stream-row-104')).toBeInTheDocument(); + }); + + // Verify on-chain stream IDs are shown rather than UUIDs + expect(screen.getByTestId('stream-row-101')).toHaveTextContent('101'); + expect(screen.getByTestId('stream-row-101')).not.toHaveTextContent('uuid-active-1'); + + // Verify statuses rendered correctly in the UI + expect(screen.getByTestId('stream-row-101')).toHaveTextContent(/active/i); + expect(screen.getByTestId('stream-row-102')).toHaveTextContent(/paused/i); + expect(screen.getByTestId('stream-row-103')).toHaveTextContent(/cancelled/i); + expect(screen.getByTestId('stream-row-104')).toHaveTextContent(/completed/i); + + // Verify KPI ribbon rendered + expect(screen.getByTestId('kpi-ribbon')).toBeInTheDocument(); + expect(screen.getByTestId('kpi-active')).toHaveTextContent('1'); // only stream 101 is active with runway + }); + + it('renders error state on API failure', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }); + global.fetch = fetchMock; + + render(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('Failed to load streams (500)'); + }); + }); +}); diff --git a/frontend/src/hooks/useStreams.ts b/frontend/src/hooks/useStreams.ts index 3295fd09..5eb03bee 100644 --- a/frontend/src/hooks/useStreams.ts +++ b/frontend/src/hooks/useStreams.ts @@ -2,6 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { Stream, StreamStatus } from '@/types/analytics'; +import { getApiBaseUrl } from '@/lib/api/_shared'; +import { TOKEN_ADDRESSES } from '@/lib/soroban'; +import { getDefaultTokenDecimals } from '@/utils/amount'; const VALID_STATUSES: StreamStatus[] = ['active', 'paused', 'completed', 'cancelled']; @@ -15,41 +18,172 @@ function num(value: unknown, fallback = 0): number { return Number.isFinite(parsed) ? parsed : fallback; } +function parseBigIntSafe(val: unknown): bigint { + if (val === null || val === undefined) return 0n; + try { + const s = String(val).trim(); + if (!s) return 0n; + const [whole] = s.split('.'); + return BigInt(whole || '0'); + } catch { + return 0n; + } +} + +export function getDefaultStreamsEndpoint(): string { + const base = getApiBaseUrl(); + return `${base}${base.endsWith('/v1') ? '' : '/v1'}/streams`; +} + export function normalizeStream(raw: unknown): Stream | null { if (!raw || typeof raw !== 'object') return null; const record = raw as Record; - const id = str(record.id ?? record.streamId ?? record.stream_id); + // Prefer on-chain streamId over database UUID id (issue #1709) + const onChainId = record.streamId ?? record.stream_id; + const id = + onChainId !== undefined && onChainId !== null && String(onChainId).trim() !== '' + ? str(onChainId) + : str(record.id); if (!id) return null; - const tokenRecord = (record.token ?? {}) as Record; - const status = str(record.status, 'active') as StreamStatus; + const tokenRecord = (record.token && typeof record.token === 'object' + ? record.token + : {}) as Record; + + const tokenAddress = str( + tokenRecord.address ?? record.tokenAddress ?? record.token_address, + ); + + // Resolve token symbol: from token object, or matching tokenAddress against known tokens + let symbol = str(tokenRecord.symbol ?? record.tokenSymbol ?? record.token_symbol); + if (!symbol && tokenAddress) { + const knownEntry = Object.entries(TOKEN_ADDRESSES).find( + ([, addr]) => addr.toLowerCase() === tokenAddress.toLowerCase(), + ); + if (knownEntry) { + symbol = knownEntry[0]; + } else { + symbol = `${tokenAddress.slice(0, 6)}...${tokenAddress.slice(-4)}`; + } + } + if (!symbol) symbol = 'UNKNOWN'; + + // Resolve decimals: explicit decimals take precedence, otherwise default based on symbol (never silent 18) + const rawDecimals = tokenRecord.decimals ?? record.tokenDecimals ?? record.token_decimals; + const decimals = + rawDecimals !== undefined && rawDecimals !== null + ? num(rawDecimals, getDefaultTokenDecimals(symbol)) + : getDefaultTokenDecimals(symbol); + + // Timestamps: map REST startTime / endTime without zero dates + const startTimestamp = num( + record.startTimestamp ?? + record.start_timestamp ?? + record.startTime ?? + record.start_time, + ); + + const endTimestamp = num( + record.endTimestamp ?? + record.end_timestamp ?? + record.endTime ?? + record.end_time, + ); + + const cliffTimestamp = num( + record.cliffTimestamp ?? + record.cliff_timestamp ?? + record.cliffTime ?? + record.cliff_time, + ); + + const lastActionLedger = num( + record.lastActionLedger ?? + record.last_action_ledger ?? + record.lastLedger ?? + record.last_ledger, + ); + + const createdAt = str( + record.createdAt ?? + record.created_at ?? + (startTimestamp > 0 + ? new Date(startTimestamp * 1000).toISOString() + : new Date().toISOString()), + ); + + // Status mapping: support explicit string as well as REST boolean flags isActive and isPaused + let status: StreamStatus; + const rawStatus = typeof record.status === 'string' ? record.status.toLowerCase().trim() : ''; + if (VALID_STATUSES.includes(rawStatus as StreamStatus)) { + status = rawStatus as StreamStatus; + } else { + const isPaused = Boolean(record.isPaused ?? record.is_paused); + const isActive = + record.isActive !== undefined + ? Boolean(record.isActive) + : record.is_active !== undefined + ? Boolean(record.is_active) + : true; + + if (isPaused) { + status = 'paused'; + } else if (isActive) { + status = 'active'; + } else { + const events = Array.isArray(record.events) ? record.events : []; + const wasCancelled = events.some( + (e: unknown) => + typeof e === 'object' && + e !== null && + ( + (e as Record).eventType === 'CANCELLED' || + (e as Record).type === 'CANCELLED' || + (e as Record).type === 'cancelled' + ), + ); + status = wasCancelled ? 'cancelled' : 'completed'; + } + } + + const depositedAmount = str(record.depositedAmount ?? record.deposited_amount ?? '0'); + const withdrawnAmount = str(record.withdrawnAmount ?? record.withdrawn_amount ?? '0'); + + let remainingBalance = str(record.remainingBalance ?? record.remaining_balance); + if (!remainingBalance) { + const dep = parseBigIntSafe(depositedAmount); + const withdr = parseBigIntSafe(withdrawnAmount); + remainingBalance = dep >= withdr ? (dep - withdr).toString() : '0'; + } + + const claimableBalance = str(record.claimableBalance ?? record.claimable_balance ?? '0'); return { id, - createdAt: str(record.createdAt ?? record.created_at ?? new Date().toISOString()), + createdAt, sender: str(record.sender ?? record.senderAddress ?? record.sender_address), recipient: str(record.recipient ?? record.recipientAddress ?? record.recipient_address), memo: record.memo ? str(record.memo) : undefined, token: { - symbol: str(tokenRecord.symbol ?? record.tokenSymbol ?? record.token_symbol, 'UNKNOWN'), - address: str(tokenRecord.address ?? record.tokenAddress ?? record.token_address), - decimals: num(tokenRecord.decimals ?? record.tokenDecimals ?? record.token_decimals, 18), + symbol, + address: tokenAddress, + decimals, logoURI: tokenRecord.logoURI ? str(tokenRecord.logoURI) : record.tokenLogo ? str(record.tokenLogo) : undefined, }, - depositedAmount: str(record.depositedAmount ?? record.deposited_amount ?? '0'), - withdrawnAmount: str(record.withdrawnAmount ?? record.withdrawn_amount ?? '0'), - remainingBalance: str(record.remainingBalance ?? record.remaining_balance ?? '0'), - claimableBalance: str(record.claimableBalance ?? record.claimable_balance ?? '0'), - startTimestamp: num(record.startTimestamp ?? record.start_timestamp), - endTimestamp: num(record.endTimestamp ?? record.end_timestamp), - cliffTimestamp: num(record.cliffTimestamp ?? record.cliff_timestamp), - status: VALID_STATUSES.includes(status) ? status : 'active', - lastActionLedger: num(record.lastActionLedger ?? record.last_action_ledger), + depositedAmount, + withdrawnAmount, + remainingBalance, + claimableBalance, + startTimestamp, + endTimestamp, + cliffTimestamp, + status, + lastActionLedger, usdRate: record.usdRate !== undefined ? num(record.usdRate, 0) : undefined, }; } @@ -57,10 +191,10 @@ export function normalizeStream(raw: unknown): Stream | null { export function normalizeStreams(raw: unknown): Stream[] { const list = Array.isArray(raw) ? raw - : Array.isArray((raw as Record)?.streams) - ? ((raw as Record).streams as unknown[]) - : Array.isArray((raw as Record)?.data) - ? ((raw as Record).data as unknown[]) + : Array.isArray((raw as Record)?.data) + ? ((raw as Record).data as unknown[]) + : Array.isArray((raw as Record)?.streams) + ? ((raw as Record).streams as unknown[]) : []; return list @@ -83,10 +217,11 @@ export interface UseStreamsResult { } export function useStreams(options: UseStreamsOptions = {}): UseStreamsResult { + const defaultEndpoint = getDefaultStreamsEndpoint(); const { refreshInterval = 30_000, address, - endpoint = '/api/streams', + endpoint = defaultEndpoint, enabled = true, } = options; @@ -112,7 +247,10 @@ export function useStreams(options: UseStreamsOptions = {}): UseStreamsResult { const load = async (showSpinner: boolean) => { if (showSpinner) setIsLoading(true); try { - const url = new URL(endpoint, window.location.origin); + const url = new URL( + endpoint, + typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000', + ); if (address) url.searchParams.set('address', address); const response = await fetch(url.toString(), { diff --git a/frontend/src/utils/export-accounting.ts b/frontend/src/utils/export-accounting.ts index a380457b..e870cf18 100644 --- a/frontend/src/utils/export-accounting.ts +++ b/frontend/src/utils/export-accounting.ts @@ -1,5 +1,6 @@ import type { ExportFormat, Stream } from '@/types/analytics'; import { toDecimalString } from '@/utils/stream-metrics'; +import { getDefaultTokenDecimals } from '@/utils/amount'; export interface ExportColumn { key: string; @@ -50,7 +51,9 @@ function toIsoDate(value: string): string { function decimalsOf(stream: Stream): number { const decimals = stream.token?.decimals; - return Number.isFinite(decimals) ? Number(decimals) : 18; + return Number.isFinite(decimals) + ? Number(decimals) + : getDefaultTokenDecimals(stream.token?.symbol ?? ''); } export function buildAccountingRow(stream: Stream): AccountingRow { diff --git a/frontend/src/utils/stream-metrics.ts b/frontend/src/utils/stream-metrics.ts index 9e51ac51..8b972cf4 100644 --- a/frontend/src/utils/stream-metrics.ts +++ b/frontend/src/utils/stream-metrics.ts @@ -5,6 +5,7 @@ import type { StreamMetrics, TokenOutflow, } from '@/types/analytics'; +import { getDefaultTokenDecimals } from './amount'; export const SECONDS_PER_HOUR = 3_600; export const SECONDS_PER_DAY = 86_400; @@ -60,7 +61,7 @@ export function toDecimalString( } export function computeStreamMetrics(stream: Stream, nowMs: number = Date.now()): StreamMetrics { - const decimals = stream.token?.decimals ?? 18; + const decimals = stream.token?.decimals ?? getDefaultTokenDecimals(stream.token?.symbol ?? ''); const deposited = formatUnits(stream.depositedAmount, decimals); const withdrawn = formatUnits(stream.withdrawnAmount, decimals); const remaining = formatUnits(stream.remainingBalance, decimals); @@ -247,7 +248,7 @@ export function buildTokenVolumeSeries( const bucket = bucketKey !== undefined ? buckets.get(bucketKey) : undefined; if (bucket) { const symbol = stream.token?.symbol ?? 'UNKNOWN'; - const decimals = stream.token?.decimals ?? 18; + const decimals = stream.token?.decimals ?? getDefaultTokenDecimals(symbol); const amount = formatUnits(stream.depositedAmount, decimals); const rate = stream.usdRate ?? 0; From c0f0096c06c70dce73b524ec07b6ebf498845290 Mon Sep 17 00:00:00 2001 From: nemeregiftbrown-byte Date: Fri, 23 Oct 2026 12:26:24 +0100 Subject: [PATCH 2/3] fix(tests): resolve unhandled rejection in rpc-pool test and dynamic timestamp in stream-analytics test --- backend/tests/rpc-pool.test.ts | 4 ++- .../src/__tests__/stream-analytics.test.tsx | 25 ++++++++++--------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/backend/tests/rpc-pool.test.ts b/backend/tests/rpc-pool.test.ts index 5e873cd9..009db6e6 100644 --- a/backend/tests/rpc-pool.test.ts +++ b/backend/tests/rpc-pool.test.ts @@ -163,9 +163,11 @@ describe('RpcPool', () => { return new Promise(() => undefined); }); + const assertion = expect(resultPromise).rejects.toThrow('all-hang timed out after 2000ms'); + await vi.advanceTimersByTimeAsync(2000); - await expect(resultPromise).rejects.toThrow('all-hang timed out after 2000ms'); + await assertion; expect(capturedSignal.aborted).toBe(true); expect(vi.getTimerCount()).toBe(0); }); diff --git a/frontend/src/__tests__/stream-analytics.test.tsx b/frontend/src/__tests__/stream-analytics.test.tsx index 4832cd45..8077086b 100644 --- a/frontend/src/__tests__/stream-analytics.test.tsx +++ b/frontend/src/__tests__/stream-analytics.test.tsx @@ -474,6 +474,7 @@ describe('Mounted AnalyticsPage against backend responses (Issue #1709)', () => }); it('mounts and renders active, paused, completed, and cancelled streams from backend response', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); const backendData = { data: [ { @@ -484,11 +485,11 @@ describe('Mounted AnalyticsPage against backend responses (Issue #1709)', () => tokenAddress: TOKEN_ADDRESSES.USDC, depositedAmount: '1000000000', withdrawnAmount: '250000000', - startTime: NOW_SECONDS - 86400 * 5, - endTime: NOW_SECONDS + 86400 * 10, + startTime: nowSeconds - 86400 * 5, + endTime: nowSeconds + 86400 * 10, isActive: true, isPaused: false, - createdAt: '2025-01-10T00:00:00Z', + createdAt: new Date((nowSeconds - 86400 * 5) * 1000).toISOString(), }, { id: 'uuid-paused-2', @@ -498,11 +499,11 @@ describe('Mounted AnalyticsPage against backend responses (Issue #1709)', () => tokenAddress: TOKEN_ADDRESSES.XLM, depositedAmount: '500000000', withdrawnAmount: '100000000', - startTime: NOW_SECONDS - 86400 * 3, - endTime: NOW_SECONDS + 86400 * 7, + startTime: nowSeconds - 86400 * 3, + endTime: nowSeconds + 86400 * 7, isActive: true, isPaused: true, - createdAt: '2025-01-12T00:00:00Z', + createdAt: new Date((nowSeconds - 86400 * 3) * 1000).toISOString(), }, { id: 'uuid-cancelled-3', @@ -512,12 +513,12 @@ describe('Mounted AnalyticsPage against backend responses (Issue #1709)', () => tokenAddress: TOKEN_ADDRESSES.EURC, depositedAmount: '300000000', withdrawnAmount: '50000000', - startTime: NOW_SECONDS - 86400 * 8, - endTime: NOW_SECONDS + 86400 * 2, + startTime: nowSeconds - 86400 * 8, + endTime: nowSeconds + 86400 * 2, isActive: false, isPaused: false, events: [{ eventType: 'CANCELLED' }], - createdAt: '2025-01-07T00:00:00Z', + createdAt: new Date((nowSeconds - 86400 * 8) * 1000).toISOString(), }, { id: 'uuid-completed-4', @@ -527,12 +528,12 @@ describe('Mounted AnalyticsPage against backend responses (Issue #1709)', () => tokenAddress: TOKEN_ADDRESSES.USDC, depositedAmount: '200000000', withdrawnAmount: '200000000', - startTime: NOW_SECONDS - 86400 * 20, - endTime: NOW_SECONDS - 86400 * 2, + startTime: nowSeconds - 86400 * 20, + endTime: nowSeconds - 86400 * 2, isActive: false, isPaused: false, events: [{ eventType: 'COMPLETED' }], - createdAt: '2024-12-25T00:00:00Z', + createdAt: new Date((nowSeconds - 86400 * 20) * 1000).toISOString(), }, ], total: 4, From e975a5ed899479405072a206607c7a8ae2fa90c7 Mon Sep 17 00:00:00 2001 From: nemeregiftbrown-byte Date: Fri, 23 Oct 2026 13:09:28 +0100 Subject: [PATCH 3/3] fix(deps): bump next to 16.4.2 to resolve critical security vulnerabilities --- frontend/package.json | 2 +- package-lock.json | 210 ++++++++++-------------------------------- package.json | 2 +- 3 files changed, 50 insertions(+), 164 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 2599d89a..63982da0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,7 @@ "@tanstack/react-query": "^5.101.0", "@tanstack/react-virtual": "^3.14.9", "lucide-react": "^0.575.0", - "next": "16.3.8", + "next": "16.4.2", "next-themes": "^0.4.6", "react": "19.2.7", "react-dom": "19.2.7", diff --git a/package-lock.json b/package-lock.json index e15e86b8..d2282e85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "concurrently": "^9.1.2", "husky": "^9.1.7", "lint-staged": "^17.0.7", - "next": "16.3.8", + "next": "16.4.2", "vitest": "^3.2.7" }, "optionalDependencies": { @@ -158,7 +158,7 @@ "@tanstack/react-query": "^5.101.0", "@tanstack/react-virtual": "^3.14.9", "lucide-react": "^0.575.0", - "next": "16.3.8", + "next": "16.4.2", "next-themes": "^0.4.6", "react": "19.2.7", "react-dom": "19.2.7", @@ -587,7 +587,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -597,7 +597,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -631,7 +631,7 @@ "version": "7.29.9", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.8" @@ -691,7 +691,7 @@ "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -1756,9 +1756,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1775,9 +1772,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1794,9 +1788,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1813,9 +1804,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1832,9 +1820,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1851,9 +1836,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1870,9 +1852,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1889,9 +1868,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1908,9 +1884,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1933,9 +1906,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1958,9 +1928,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1983,9 +1950,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2008,9 +1972,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2033,9 +1994,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2058,9 +2016,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2083,9 +2038,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2292,9 +2244,9 @@ } }, "node_modules/@next/env": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz", - "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.2.tgz", + "integrity": "sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -2308,9 +2260,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz", - "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.2.tgz", + "integrity": "sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==", "cpu": [ "arm64" ], @@ -2324,9 +2276,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz", - "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.2.tgz", + "integrity": "sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==", "cpu": [ "x64" ], @@ -2340,15 +2292,12 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz", - "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.2.tgz", + "integrity": "sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2359,15 +2308,12 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz", - "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.2.tgz", + "integrity": "sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2378,15 +2324,12 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz", - "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.2.tgz", + "integrity": "sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2397,15 +2340,12 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz", - "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.2.tgz", + "integrity": "sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2416,9 +2356,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz", - "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.2.tgz", + "integrity": "sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==", "cpu": [ "arm64" ], @@ -2432,9 +2372,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz", - "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.2.tgz", + "integrity": "sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==", "cpu": [ "x64" ], @@ -5946,7 +5886,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -10858,19 +10798,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/index-to-position": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", - "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -12012,7 +11939,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.25.4", @@ -12310,12 +12237,12 @@ } }, "node_modules/next": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz", - "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/next/-/next-16.4.2.tgz", + "integrity": "sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==", "license": "MIT", "dependencies": { - "@next/env": "16.3.8", + "@next/env": "16.4.2", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -12329,15 +12256,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.8", - "@next/swc-darwin-x64": "16.3.8", - "@next/swc-linux-arm64-gnu": "16.3.8", - "@next/swc-linux-arm64-musl": "16.3.8", - "@next/swc-linux-x64-gnu": "16.3.8", - "@next/swc-linux-x64-musl": "16.3.8", - "@next/swc-win32-arm64-msvc": "16.3.8", - "@next/swc-win32-x64-msvc": "16.3.8", - "sharp": "^0.35.4" + "@next/swc-darwin-arm64": "16.4.2", + "@next/swc-darwin-x64": "16.4.2", + "@next/swc-linux-arm64-gnu": "16.4.2", + "@next/swc-linux-arm64-musl": "16.4.2", + "@next/swc-linux-x64-gnu": "16.4.2", + "@next/swc-linux-x64-musl": "16.4.2", + "@next/swc-win32-arm64-msvc": "16.4.2", + "@next/swc-win32-x64-msvc": "16.4.2", + "sharp": "^0.35.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -13097,47 +13024,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", - "pathe": "^2.0.3" - } - }, - "node_modules/playwright": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", - "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.64.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/playwright-core": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", - "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/pkg-types": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz", diff --git a/package.json b/package.json index fe369163..ca769359 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "concurrently": "^9.1.2", "husky": "^9.1.7", "lint-staged": "^17.0.7", - "next": "16.3.8", + "next": "16.4.2", "vitest": "^3.2.7" }, "lint-staged": {