From 49b41edcb0e24afbaa13af52ddbd6fdf22ce5a8e Mon Sep 17 00:00:00 2001 From: rednakse Date: Thu, 2 Apr 2026 03:14:49 +0300 Subject: [PATCH] Refine users table and reconnect handling --- docs/PLAN.md | 1 + docs/PROJECT_INDEX.md | 16 +-- server/lib/snapshot.ts | 13 +- server/lib/traffic.ts | 10 ++ src/App.test.tsx | 36 +++++- src/App.tsx | 254 ++++++++++++++++++++++++++++++---------- src/app.css | 14 +++ src/lib/panelText.ts | 22 +++- src/shared/contracts.ts | 1 + src/test/setup.ts | 14 ++- 10 files changed, 308 insertions(+), 73 deletions(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index 27e5ff0..c05ee9a 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -45,3 +45,4 @@ Updated: 2026-04-02 28. Reworked Users actions into fixed-width icon buttons, added edit-in-modal flow, generated credentials only for new users, and blocked action buttons while commands are in flight. 29. Added backend user-update support plus runtime stop control, then verified both in Docker by updating `u-1` and stopping the real bundled 3proxy process through the API. 30. Added a websocket heartbeat so time-based status transitions such as `live -> idle/warn` are recalculated predictably even when no new proxy events arrive. +31. Moved proxy-copy into the Users actions column, added a last-seen/online column from parsed 3proxy logs, and introduced bounded websocket/API reconnect attempts with a visible connection banner and forced logout after full recovery failure. diff --git a/docs/PROJECT_INDEX.md b/docs/PROJECT_INDEX.md index 8cd7b66..7f226fb 100644 --- a/docs/PROJECT_INDEX.md +++ b/docs/PROJECT_INDEX.md @@ -23,16 +23,16 @@ Updated: 2026-04-02 ## Frontend - `src/main.tsx`: application bootstrap -- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, hash-based tab history, websocket snapshot patch sync, icon-based user actions, create/edit user modal flows, runtime stop control, localized labels, early theme application, and protected panel mutations +- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, hash-based tab history, websocket snapshot patch sync, bounded reconnect/API fallback policy with connection notices, log-derived last-seen user labels, icon-based user actions, create/edit user modal flows, runtime stop control, localized labels, early theme application, and protected panel mutations - `src/SystemTab.tsx`: Settings tab with separate panel-settings and services cards, editable proxy endpoint, dirty-draft protection against incoming live sync, unified service type editing, remove confirmation, and generated config preview -- `src/App.test.tsx`: login-gate, preferences persistence, hash-tab restoration, websocket-sync safety, generated-credential/create-edit modal flows, runtime stop, pause/resume, delete-confirm, and settings-save UI tests -- `src/app.css`: full panel styling including fixed-width icon action buttons and busy-state treatment +- `src/App.test.tsx`: login-gate, preferences persistence, hash-tab restoration, websocket-sync safety, reconnect/logout fallback handling, generated-credential/create-edit modal flows, runtime stop, pause/resume, delete-confirm, and settings-save UI tests +- `src/app.css`: full panel styling including fixed-width icon action buttons, busy-state treatment, and connection banner styling - `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot - `src/lib/3proxy.ts`: formatting and status helpers - `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules - `src/lib/panelPreferences.ts`: `localStorage`-backed panel language/theme preferences plus theme application helpers with `system` as the default theme -- `src/lib/panelText.ts`: English/Russian UI text catalog for the panel shell, user-edit actions, and runtime controls -- `src/shared/contracts.ts`: shared panel, service, user, and API data contracts +- `src/lib/panelText.ts`: English/Russian UI text catalog for the panel shell, user-edit actions, runtime controls, last-seen labels, and connection recovery notices +- `src/shared/contracts.ts`: shared panel, service, user, and API data contracts including per-user last-seen metadata - `src/shared/validation.ts`: shared validation for user creation, system edits, service type mapping, and quota conversion - `src/test/setup.ts`: Testing Library matchers plus browser WebSocket test double @@ -44,10 +44,10 @@ Updated: 2026-04-02 - `server/lib/auth.ts`: expiring token issuance and bearer-token verification for the panel - `server/lib/config.ts`: 3proxy config renderer, validation, and dashboard derivation for SOCKS/HTTP managed services - `server/lib/config.test.ts`: config-generation regression tests -- `server/lib/liveSync.ts`: websocket broadcaster that emits `snapshot.init` and top-level `snapshot.patch` messages from runtime/store changes plus a heartbeat for time-based status decay +- `server/lib/liveSync.ts`: websocket broadcaster that emits `snapshot.init` and top-level `snapshot.patch` messages from runtime/store changes plus heartbeat-driven status decay and bounded reconnect-friendly behavior - `server/lib/liveSync.test.ts`: regression tests for patch-only websocket payload generation and heartbeat-driven refreshes -- `server/lib/snapshot.ts`: runtime-backed dashboard snapshot assembly that combines stored panel state with parsed 3proxy traffic observations -- `server/lib/traffic.ts`: 3proxy access-log reader that derives current user usage, recent activity, daily totals, and lightweight live-connection estimates +- `server/lib/snapshot.ts`: runtime-backed dashboard snapshot assembly that combines stored panel state with parsed 3proxy traffic observations and derives user last-seen/status output +- `server/lib/traffic.ts`: 3proxy access-log reader that derives current user usage, recent activity, last-seen timestamps, daily totals, and lightweight live-connection estimates - `server/lib/traffic.test.ts`: parser and empty-runtime regression tests for log-derived traffic metrics - `server/lib/runtime.ts`: managed 3proxy process controller with start/stop/restart/reload operations - `server/lib/store.ts`: JSON-backed persistent state store with legacy admin-service migration diff --git a/server/lib/snapshot.ts b/server/lib/snapshot.ts index d876b02..1248077 100644 --- a/server/lib/snapshot.ts +++ b/server/lib/snapshot.ts @@ -11,7 +11,12 @@ export async function getDashboardSnapshot( const state = await store.read(); const previewConfig = render3proxyConfig(state, runtimePaths); const traffic = await readObservedTraffic(runtimePaths, state.userRecords); - const observedUsers = deriveObservedUsers(state.userRecords, traffic.userBytesByName, traffic.recentUsers); + const observedUsers = deriveObservedUsers( + state.userRecords, + traffic.userBytesByName, + traffic.lastSeenByName, + traffic.recentUsers, + ); const observed: ObservedRuntimeState = { totalBytes: traffic.totalBytes, liveConnections: traffic.liveConnections, @@ -26,14 +31,17 @@ export async function getDashboardSnapshot( function deriveObservedUsers( users: ProxyUserRecord[], userBytesByName: Map, + lastSeenByName: Map, recentUsers: Set, ): ProxyUserRecord[] { return users.map((user) => { const usedBytes = userBytesByName.get(user.username) ?? 0; + const lastSeenAt = lastSeenByName.get(user.username) ?? null; if (user.paused) { return { ...user, + lastSeenAt, usedBytes, status: 'idle', }; @@ -42,6 +50,7 @@ function deriveObservedUsers( if (user.quotaBytes !== null && usedBytes >= user.quotaBytes) { return { ...user, + lastSeenAt, usedBytes, status: 'fail', }; @@ -50,6 +59,7 @@ function deriveObservedUsers( if (user.quotaBytes !== null && user.quotaBytes > 0 && usedBytes / user.quotaBytes >= 0.8) { return { ...user, + lastSeenAt, usedBytes, status: recentUsers.has(user.username) ? 'live' : 'warn', }; @@ -57,6 +67,7 @@ function deriveObservedUsers( return { ...user, + lastSeenAt, usedBytes, status: recentUsers.has(user.username) ? 'live' : 'idle', }; diff --git a/server/lib/traffic.ts b/server/lib/traffic.ts index 66d6438..e3fe3e8 100644 --- a/server/lib/traffic.ts +++ b/server/lib/traffic.ts @@ -14,6 +14,7 @@ export interface ObservedTraffic { activeUsers: number; daily: DailyTrafficBucket[]; userBytesByName: Map; + lastSeenByName: Map; recentUsers: Set; } @@ -34,6 +35,7 @@ export async function readObservedTraffic( const history = buildHistoryDays(historyStart, HISTORY_DAYS); const dailyTotals = new Map(history.map((entry) => [entry.key, 0])); const userBytesByName = new Map(users.map((user) => [user.username, 0])); + const lastSeenByName = new Map(users.map((user) => [user.username, null])); const recentUsers = new Set(); let liveConnections = 0; @@ -47,6 +49,13 @@ export async function readObservedTraffic( userBytesByName.set(record.username, (userBytesByName.get(record.username) ?? 0) + record.bytes); } + if (lastSeenByName.has(record.username)) { + const currentLastSeen = lastSeenByName.get(record.username); + if (!currentLastSeen || record.timestamp.toISOString() > currentLastSeen) { + lastSeenByName.set(record.username, record.timestamp.toISOString()); + } + } + const ageMs = now.getTime() - record.timestamp.getTime(); if (ageMs >= 0 && ageMs <= RECENT_WINDOW_MS) { recentUsers.add(record.username); @@ -76,6 +85,7 @@ export async function readObservedTraffic( activeUsers, daily, userBytesByName, + lastSeenByName, recentUsers, }; } diff --git a/src/App.test.tsx b/src/App.test.tsx index dc624af..4ad10c1 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import App from './App'; import { fallbackDashboardSnapshot } from './data/mockDashboard'; import { MockWebSocket } from './test/setup'; @@ -115,6 +115,19 @@ describe('App login gate', () => { expect(screen.getAllByText(/^idle$/i).length).toBeGreaterThan(0); }); + it('shows online data in the users table and keeps proxy copy inside actions', async () => { + const user = userEvent.setup(); + render(); + + await loginIntoPanel(user); + await user.click(screen.getByRole('button', { name: /users/i })); + + expect(screen.getByRole('columnheader', { name: /online/i })).toBeInTheDocument(); + expect(screen.queryByRole('columnheader', { name: /^proxy$/i })).not.toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: /copy proxy link/i }).length).toBeGreaterThan(0); + expect(screen.getAllByText(/^now$/i).length).toBeGreaterThan(0); + }); + it('opens add-user flow in a modal and closes it on escape', async () => { const user = userEvent.setup(); render(); @@ -249,6 +262,27 @@ describe('App login gate', () => { expect(screen.getByLabelText(/proxy endpoint/i)).toHaveValue('draft.example.net'); }); + it('shows a connection notice and exits the panel after websocket and api retries are exhausted', async () => { + vi.useFakeTimers(); + MockWebSocket.connectMode = 'close'; + window.sessionStorage.setItem( + '3proxy-ui-panel-session', + JSON.stringify({ + token: 'local-ui-fallback', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }), + ); + + render(); + await vi.advanceTimersByTimeAsync(2_100); + + expect(screen.getByText(/reconnecting websocket|переподключаем websocket/i)).toBeInTheDocument(); + + await vi.advanceTimersByTimeAsync(25_000); + + expect(screen.getByRole('button', { name: /open panel/i })).toBeInTheDocument(); + }, 10_000); + it('warns before deleting a service and removes linked users after confirmation', async () => { const user = userEvent.setup(); render(); diff --git a/src/App.tsx b/src/App.tsx index 3be7854..651dbf7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,6 +40,9 @@ const tabs: Array<{ id: TabId; textKey: 'dashboard' | 'users' | 'settings' }> = const SESSION_KEY = '3proxy-ui-panel-session'; const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000; const LIVE_SYNC_RECONNECT_MS = 2000; +const LIVE_SYNC_RESTORE_MS = 30 * 1000; +const MAX_HTTP_RETRIES = 5; +const MAX_WS_RETRIES = 5; interface StoredSession { token: string; @@ -547,8 +550,8 @@ function UsersTab({ {text.common.status} {text.users.used} {text.users.remaining} + {text.users.online} {text.users.share} - {text.users.proxy} {text.users.actions} @@ -593,20 +596,19 @@ function UsersTab({ {formatBytes(user.usedBytes)} {formatQuotaStateLabel(user.usedBytes, user.quotaBytes, preferences.language)} + {formatLastSeenLabel(user, preferences.language)} {formatTrafficShare(user.usedBytes, snapshot.traffic.totalBytes)} - - handleCopy(user.id, proxyLink)} - disabled={!service} - > - {copiedId === user.id ? : } - -
+ handleCopy(user.id, proxyLink)} + disabled={!service || isPendingRow} + > + {copiedId === user.id ? : } + (() => loadStoredSession()); const [activeTab, setActiveTab] = useState(() => readTabFromHash(window.location.hash)); + const [connectionNotice, setConnectionNotice] = useState(null); const [snapshot, setSnapshot] = useState(fallbackDashboardSnapshot); const text = getPanelText(preferences.language); @@ -832,6 +835,7 @@ export default function App() { const resetSession = () => { clearStoredSession(); + setConnectionNotice(null); setSession(null); }; @@ -879,6 +883,15 @@ export default function App() { let cancelled = false; let reconnectTimer: number | null = null; let socket: WebSocket | null = null; + let isHttpFallbackRunning = false; + let wsAttempts = 0; + + const clearReconnectTimer = () => { + if (reconnectTimer !== null) { + window.clearTimeout(reconnectTimer); + reconnectTimer = null; + } + }; const refreshSnapshot = async () => { try { @@ -890,6 +903,7 @@ export default function App() { if (!cancelled && payload) { setSnapshot(payload); + return true; } } catch (error) { if (error instanceof SessionExpiredError) { @@ -898,66 +912,138 @@ export default function App() { } } } + + return false; }; - void refreshSnapshot(); - const liveSyncUrl = getLiveSyncUrl(session.token); - if (typeof window.WebSocket !== 'undefined' && liveSyncUrl) { - const connect = () => { + const scheduleBackgroundReconnect = () => { + if (cancelled || reconnectTimer !== null) { + return; + } + + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + wsAttempts = 0; + connectWebSocket(); + }, LIVE_SYNC_RESTORE_MS); + }; + + const runHttpFallback = async () => { + if (cancelled || isHttpFallbackRunning) { + return; + } + + isHttpFallbackRunning = true; + + for (let attempt = 1; attempt <= MAX_HTTP_RETRIES && !cancelled; attempt += 1) { + setConnectionNotice(buildConnectionNotice(text.connection.httpRetry, text.connection.attempt, attempt, MAX_HTTP_RETRIES)); + const success = await refreshSnapshot(); + + if (success) { + setConnectionNotice(null); + isHttpFallbackRunning = false; + scheduleBackgroundReconnect(); + return; + } + + if (attempt < MAX_HTTP_RETRIES) { + await waitForDelay(LIVE_SYNC_RECONNECT_MS); + } + } + + isHttpFallbackRunning = false; + if (!cancelled) { + setConnectionNotice(text.connection.sessionClosed); + resetSession(); + } + }; + + const scheduleWebSocketReconnect = () => { + if (cancelled || isHttpFallbackRunning || reconnectTimer !== null) { + return; + } + + if (wsAttempts >= MAX_WS_RETRIES) { + void runHttpFallback(); + return; + } + + wsAttempts += 1; + setConnectionNotice( + buildConnectionNotice(text.connection.websocketRetry, text.connection.attempt, wsAttempts, MAX_WS_RETRIES), + ); + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + connectWebSocket(); + }, LIVE_SYNC_RECONNECT_MS); + }; + + const connectWebSocket = () => { + if (cancelled) { + return; + } + + if (typeof window.WebSocket === 'undefined' || !liveSyncUrl) { + void runHttpFallback(); + return; + } + + const nextSocket = new window.WebSocket(liveSyncUrl); + socket = nextSocket; + + nextSocket.addEventListener('open', () => { + wsAttempts = 0; + setConnectionNotice(null); + }); + + nextSocket.addEventListener('message', (event) => { + const message = parseDashboardSyncMessage(event.data); + if (!message || cancelled) { + return; + } + + if (message.type === 'snapshot.init') { + setSnapshot(message.snapshot); + setConnectionNotice(null); + return; + } + + if (message.type === 'snapshot.patch') { + setSnapshot((current) => applySnapshotPatch(current, message.patch)); + setConnectionNotice(null); + return; + } + + resetSession(); + }); + + nextSocket.addEventListener('error', () => { + nextSocket.close(); + }); + + nextSocket.addEventListener('close', () => { + if (socket === nextSocket) { + socket = null; + } + if (cancelled) { return; } - socket = new window.WebSocket(liveSyncUrl); + scheduleWebSocketReconnect(); + }); + }; - socket.addEventListener('message', (event) => { - const message = parseDashboardSyncMessage(event.data); - if (!message || cancelled) { - return; - } - - if (message.type === 'snapshot.init') { - setSnapshot(message.snapshot); - return; - } - - if (message.type === 'snapshot.patch') { - setSnapshot((current) => applySnapshotPatch(current, message.patch)); - return; - } - - resetSession(); - }); - - socket.addEventListener('error', () => { - socket?.close(); - }); - - socket.addEventListener('close', () => { - if (cancelled || reconnectTimer !== null) { - return; - } - - reconnectTimer = window.setTimeout(() => { - reconnectTimer = null; - void refreshSnapshot(); - connect(); - }, LIVE_SYNC_RECONNECT_MS); - }); - }; - - connect(); - } + void refreshSnapshot(); + connectWebSocket(); return () => { cancelled = true; - if (reconnectTimer !== null) { - window.clearTimeout(reconnectTimer); - } + clearReconnectTimer(); socket?.close(); }; - }, [session]); + }, [session, text.connection.attempt, text.connection.httpRetry, text.connection.sessionClosed, text.connection.websocketRetry]); if (!session) { return ; @@ -1262,6 +1348,7 @@ export default function App() { onSaveSystem={handleSaveSystem} /> ) : null} + {connectionNotice ?
{connectionNotice}
: null} ); @@ -1458,6 +1545,49 @@ function getHashForTab(tab: TabId): string { } } +function formatLastSeenLabel(user: ProxyUserRecord, language: PanelPreferences['language']): string { + const text = getPanelText(language); + + if (user.status === 'live') { + return text.users.onlineNow; + } + + if (!user.lastSeenAt) { + return text.users.neverOnline; + } + + const lastSeen = new Date(user.lastSeenAt).getTime(); + if (!Number.isFinite(lastSeen)) { + return text.users.neverOnline; + } + + const diffMs = Date.now() - lastSeen; + if (diffMs < 60_000) { + return text.users.onlineNow; + } + + const formatter = new Intl.RelativeTimeFormat(language === 'ru' ? 'ru-RU' : 'en-US', { + numeric: 'auto', + }); + + const minutes = Math.round(diffMs / 60_000); + if (minutes < 60) { + return formatter.format(-minutes, 'minute'); + } + + const hours = Math.round(minutes / 60); + if (hours < 24) { + return formatter.format(-hours, 'hour'); + } + + const days = Math.round(hours / 24); + return formatter.format(-days, 'day'); +} + +function buildConnectionNotice(prefix: string, attemptLabel: string, attempt: number, maxAttempts: number): string { + return `${prefix}: ${attemptLabel} ${attempt}/${maxAttempts}.`; +} + function toUserEditorValues(user: ProxyUserRecord): CreateUserInput { return { username: user.username, @@ -1474,3 +1604,9 @@ function generateUsername(): string { function generatePassword(): string { return `pw-${Math.random().toString(36).slice(2, 8)}-${Math.random().toString(36).slice(2, 6)}`; } + +function waitForDelay(delayMs: number): Promise { + return new Promise((resolve) => { + window.setTimeout(resolve, delayMs); + }); +} diff --git a/src/app.css b/src/app.css index 40c988e..024c43e 100644 --- a/src/app.css +++ b/src/app.css @@ -735,6 +735,20 @@ pre { justify-content: flex-end; } +.connection-banner { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 40; + max-width: min(360px, calc(100vw - 32px)); + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--warning) 45%, var(--border-strong)); + border-radius: 10px; + background: color-mix(in srgb, var(--surface) 82%, var(--warning) 18%); + color: var(--text); + box-shadow: 0 10px 24px rgba(17, 24, 39, 0.14); +} + @media (max-width: 960px) { .shell { width: calc(100vw - 24px); diff --git a/src/lib/panelText.ts b/src/lib/panelText.ts index 983d460..6bac047 100644 --- a/src/lib/panelText.ts +++ b/src/lib/panelText.ts @@ -57,6 +57,7 @@ const text = { light: 'Light', dark: 'Dark', system: 'System', + disconnected: 'Disconnected', }, dashboard: { service: 'Service', @@ -79,8 +80,8 @@ const text = { endpoint: 'Endpoint', used: 'Used', remaining: 'Remaining', + online: 'Online', share: 'Share', - proxy: 'Proxy', actions: 'Actions', addUser: 'Add user', editUser: 'Edit user', @@ -99,6 +100,14 @@ const text = { deletePrompt: 'Remove profile', deletePromptSuffix: '? This action will delete the user entry from the current panel state.', deleteAction: 'Delete user', + onlineNow: 'Now', + neverOnline: 'Never', + }, + connection: { + websocketRetry: 'Live sync disconnected. Reconnecting websocket', + httpRetry: 'Live sync unavailable. Retrying API sync', + attempt: 'attempt', + sessionClosed: 'Connection to the panel was lost. Sign in again.', }, settings: { panelTitle: 'Panel settings', @@ -183,6 +192,7 @@ const text = { light: 'Светлый', dark: 'Темный', system: 'Системный', + disconnected: 'Нет связи', }, dashboard: { service: 'Сервис', @@ -205,8 +215,8 @@ const text = { endpoint: 'Точка входа', used: 'Использовано', remaining: 'Остаток', + online: 'В сети', share: 'Доля', - proxy: 'Прокси', actions: 'Действия', addUser: 'Добавить пользователя', editUser: 'Редактирование пользователя', @@ -225,6 +235,14 @@ const text = { deletePrompt: 'Удалить профиль', deletePromptSuffix: '? Это действие удалит пользователя из текущего состояния панели.', deleteAction: 'Удалить пользователя', + onlineNow: 'Сейчас', + neverOnline: 'Никогда', + }, + connection: { + websocketRetry: 'Связь live sync потеряна. Переподключаем WebSocket', + httpRetry: 'Live sync недоступен. Повторяем синхронизацию через API', + attempt: 'попытка', + sessionClosed: 'Связь с панелью потеряна. Войдите снова.', }, settings: { panelTitle: 'Настройки панели', diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index b510559..abfe8ab 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -28,6 +28,7 @@ export interface ProxyUserRecord { status: Exclude; usedBytes: number; quotaBytes: number | null; + lastSeenAt?: string | null; paused?: boolean; } diff --git a/src/test/setup.ts b/src/test/setup.ts index 47c17cc..42cfbdb 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,6 +1,6 @@ import '@testing-library/jest-dom/vitest'; import { cleanup } from '@testing-library/react'; -import { afterEach } from 'vitest'; +import { afterEach, vi } from 'vitest'; export class MockWebSocket extends EventTarget { static readonly CONNECTING = 0; @@ -8,6 +8,7 @@ export class MockWebSocket extends EventTarget { static readonly CLOSING = 2; static readonly CLOSED = 3; static instances: MockWebSocket[] = []; + static connectMode: 'open' | 'close' = 'open'; readonly CONNECTING = MockWebSocket.CONNECTING; readonly OPEN = MockWebSocket.OPEN; @@ -20,7 +21,14 @@ export class MockWebSocket extends EventTarget { super(); this.url = String(url); MockWebSocket.instances.push(this); - queueMicrotask(() => this.dispatchEvent(new Event('open'))); + queueMicrotask(() => { + if (MockWebSocket.connectMode === 'close') { + this.close(); + return; + } + + this.dispatchEvent(new Event('open')); + }); } send(_data?: string | ArrayBufferLike | Blob | ArrayBufferView): void {} @@ -45,6 +53,7 @@ export class MockWebSocket extends EventTarget { static reset(): void { this.instances = []; + this.connectMode = 'open'; } } @@ -56,5 +65,6 @@ Object.defineProperty(globalThis, 'WebSocket', { afterEach(() => { cleanup(); + vi.useRealTimers(); MockWebSocket.reset(); });