From 64d29e12415faab7eae7242233b8e9f553e62c7c Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 8 Aug 2026 15:38:11 +0100 Subject: [PATCH] Harden dashboard realtime state and controls --- apps/dashboard/src/app/app-shell.tsx | 2 + apps/dashboard/src/app/providers.tsx | 3 +- apps/dashboard/src/app/realtime-provider.tsx | 45 ++++++++++++++++ apps/dashboard/src/app/realtime-repair.ts | 26 +++++++++ apps/dashboard/src/app/route-error-page.tsx | 7 +++ apps/dashboard/src/app/router.tsx | 2 + apps/dashboard/src/app/shell-status-panel.css | 4 ++ apps/dashboard/src/app/shell-status-panel.tsx | 26 +++++++++ apps/dashboard/src/app/shell-status.ts | 39 ++++++++++++++ .../features/operations/operations-page.tsx | 36 +++---------- .../features/operations/realtime-client.ts | 6 ++- .../features/operations/realtime-fallback.ts | 54 +++++++++++++++++++ src/web/events/event-adapters.ts | 51 ++++++++++++++++++ src/web/events/event-publisher.ts | 10 ++++ src/web/events/index.ts | 2 + tests/dashboard/realtime-client.test.ts | 18 +++++++ tests/dashboard/realtime-fallback.test.ts | 38 +++++++++++++ tests/dashboard/realtime-repair.test.ts | 13 +++++ tests/dashboard/route-error-boundary.test.tsx | 10 ++++ tests/dashboard/shell-status.test.ts | 22 ++++++++ tests/web/events/event-adapters.test.ts | 39 ++++++++++++++ 21 files changed, 423 insertions(+), 30 deletions(-) create mode 100644 apps/dashboard/src/app/realtime-provider.tsx create mode 100644 apps/dashboard/src/app/realtime-repair.ts create mode 100644 apps/dashboard/src/app/route-error-page.tsx create mode 100644 apps/dashboard/src/app/shell-status-panel.css create mode 100644 apps/dashboard/src/app/shell-status-panel.tsx create mode 100644 apps/dashboard/src/app/shell-status.ts create mode 100644 apps/dashboard/src/features/operations/realtime-fallback.ts create mode 100644 src/web/events/event-adapters.ts create mode 100644 src/web/events/event-publisher.ts create mode 100644 tests/dashboard/realtime-fallback.test.ts create mode 100644 tests/dashboard/realtime-repair.test.ts create mode 100644 tests/dashboard/route-error-boundary.test.tsx create mode 100644 tests/dashboard/shell-status.test.ts create mode 100644 tests/web/events/event-adapters.test.ts diff --git a/apps/dashboard/src/app/app-shell.tsx b/apps/dashboard/src/app/app-shell.tsx index c6bb972..04aab3b 100644 --- a/apps/dashboard/src/app/app-shell.tsx +++ b/apps/dashboard/src/app/app-shell.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { NavLink, Outlet } from 'react-router-dom'; import { navigationItems } from './navigation.js'; +import { ShellStatusPanel } from './shell-status-panel.js'; export function AppShell() { const [isNavigationOpen, setIsNavigationOpen] = useState(false); @@ -31,6 +32,7 @@ export function AppShell() { ))} + {isNavigationOpen ? ( + + {confirming ? setConfirming(null)} onConfirm={() => control.mutate(confirming === 'start', { onSuccess: () => setConfirming(null) })} /> : null} + ; +} diff --git a/apps/dashboard/src/app/shell-status.ts b/apps/dashboard/src/app/shell-status.ts new file mode 100644 index 0000000..78218a2 --- /dev/null +++ b/apps/dashboard/src/app/shell-status.ts @@ -0,0 +1,39 @@ +import type { RealtimeConnectionSnapshot } from '../features/operations/realtime-client.js'; + +interface ShellStatusInput { + stats?: { isRunning: boolean; dryRunMode: boolean; invitationCooldown: { reason: string } | null }; + reddit?: { ready: boolean; loggedIn?: boolean; status?: string; error?: string }; + realtime: RealtimeConnectionSnapshot; + sync?: { syncing: boolean; syncingKind?: string | null }; +} + +export interface ShellStatus { + bot: string; + reddit: string; + realtime: string; + cooldown: string | null; + sync: string; + controlAction: 'start' | 'stop'; + controlDisabled: boolean; +} + +function sentence(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1); } + +export function deriveShellStatus(input: ShellStatusInput): ShellStatus { + const stats = input.stats; + const realtime = input.realtime.status === 'connected' + ? 'Realtime connected' + : `Realtime ${input.realtime.status}${input.realtime.isStale ? ' (stale)' : ''}`; + const reddit = input.reddit?.ready + ? input.reddit.loggedIn ? `Reddit ${input.reddit.status ?? 'authenticated'}` : 'Reddit login required' + : 'Reddit session unavailable'; + return { + bot: stats ? `${stats.isRunning ? 'Running' : 'Stopped'}${stats.dryRunMode ? ' (dry run)' : ''}` : 'Bot state unavailable', + reddit, + realtime, + cooldown: stats?.invitationCooldown?.reason ?? null, + sync: input.sync?.syncing ? `${sentence(input.sync.syncingKind ?? 'unknown')} sync active` : 'No active sync', + controlAction: stats?.isRunning ? 'stop' : 'start', + controlDisabled: stats === undefined, + }; +} diff --git a/apps/dashboard/src/features/operations/operations-page.tsx b/apps/dashboard/src/features/operations/operations-page.tsx index 6ab3005..e824190 100644 --- a/apps/dashboard/src/features/operations/operations-page.tsx +++ b/apps/dashboard/src/features/operations/operations-page.tsx @@ -1,6 +1,6 @@ -import { useEffect, useReducer, useRef, useState } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useReducer, useRef } from 'react'; import type { ActivityItem, ChatSyncStatus, DashboardEvent } from '../../../../../packages/api-contracts/src/index.js'; +import { useDashboardRealtime } from '../../app/realtime-provider.js'; import { createLiveConsoleState, reduceLiveConsoleState, @@ -9,12 +9,7 @@ import { type ConsoleLevelFilter, } from './live-console-model.js'; import { useActivityQuery, useLogsQuery, useSyncStatusQuery } from './operations-queries.js'; -import { - createBrowserEventSource, - createDashboardRealtimeClient, - queryKeysForDashboardEvent, - type RealtimeConnectionSnapshot, -} from './realtime-client.js'; +import type { RealtimeConnectionSnapshot } from './realtime-client.js'; import './operations.css'; function formatTimestamp(timestamp: number | string): string { @@ -73,7 +68,7 @@ export interface OperationsPageProps { } export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps) { - const queryClient = useQueryClient(); + const realtime = useDashboardRealtime(); const activityQuery = useActivityQuery(); const logsQuery = useLogsQuery(); const syncQuery = useSyncStatusQuery(); @@ -81,10 +76,7 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps) reduceLiveConsoleState, createLiveConsoleState(500), ); - const [connection, setConnection] = useState({ - status: 'idle', - isStale: true, - }); + const connection = realtimeEnabled ? realtime.connection : { status: 'idle', isStale: true } as const; const consoleEnd = useRef(null); useEffect(() => { @@ -103,10 +95,7 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps) }, [logsQuery.data]); useEffect(() => { - if (!realtimeEnabled || typeof EventSource === 'undefined') { - setConnection({ status: 'idle', isStale: true }); - return; - } + if (!realtimeEnabled) return; function onEvent(event: DashboardEvent): void { if (event.type === 'log.created') { const entry: ConsoleEntry = { @@ -118,18 +107,9 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps) }; dispatchConsole({ type: 'append', entry }); } - for (const queryKey of queryKeysForDashboardEvent(event)) { - void queryClient.invalidateQueries({ queryKey }); - } } - const client = createDashboardRealtimeClient({ - sourceFactory: createBrowserEventSource, - onEvent, - onStateChange: setConnection, - }); - client.start(); - return () => client.stop(); - }, [queryClient, realtimeEnabled]); + return realtime.subscribe(onEvent); + }, [realtime, realtimeEnabled]); const visibleEntries = selectVisibleConsoleEntries(consoleState); useEffect(() => { diff --git a/apps/dashboard/src/features/operations/realtime-client.ts b/apps/dashboard/src/features/operations/realtime-client.ts index daf1615..a3a0394 100644 --- a/apps/dashboard/src/features/operations/realtime-client.ts +++ b/apps/dashboard/src/features/operations/realtime-client.ts @@ -40,6 +40,7 @@ export interface DashboardRealtimeClientOptions { url?: string; sourceFactory(url: string): DashboardEventSource; onEvent(event: DashboardEvent): void; + onConnected?(context: { isReconnect: boolean }): void; onStateChange?(snapshot: RealtimeConnectionSnapshot): void; onInvalidEvent?(value: unknown): void; } @@ -54,6 +55,7 @@ export function createDashboardRealtimeClient( ): DashboardRealtimeClient { let source: DashboardEventSource | undefined; let snapshot: RealtimeConnectionSnapshot = { status: 'idle', isStale: true }; + let hasConnected = false; function update(next: RealtimeConnectionSnapshot): void { snapshot = next; @@ -89,6 +91,8 @@ export function createDashboardRealtimeClient( source = options.sourceFactory(options.url ?? '/api/events/stream'); source.addEventListener('open', () => { update({ ...snapshot, status: 'connected', isStale: false }); + options.onConnected?.({ isReconnect: hasConnected }); + hasConnected = true; }); source.addEventListener('error', () => { update({ ...snapshot, status: 'reconnecting', isStale: true }); @@ -124,7 +128,7 @@ export function queryKeysForDashboardEvent(event: DashboardEvent): readonly Quer case 'sync.failed': return [['operations', 'sync-status'], ['conversations'], ['media']]; case 'reddit.session.changed': - return [['reddit', 'session']]; + return [['dashboard', 'login', 'status']]; case 'log.created': return []; } diff --git a/apps/dashboard/src/features/operations/realtime-fallback.ts b/apps/dashboard/src/features/operations/realtime-fallback.ts new file mode 100644 index 0000000..f25f357 --- /dev/null +++ b/apps/dashboard/src/features/operations/realtime-fallback.ts @@ -0,0 +1,54 @@ +export interface PollingScheduler { + setInterval(callback: () => void, intervalMs: number): unknown; + clearInterval(handle: unknown): void; +} + +export interface VisibilitySource { + isVisible(): boolean; + subscribe(listener: () => void): () => void; +} + +export interface VisibilityAwarePollingFallback { + start(): void; + stop(): void; + setStale(stale: boolean): void; +} + +export function createVisibilityAwarePollingFallback(options: { + intervalMs: number; + poll(): void; + scheduler: PollingScheduler; + visibility: VisibilitySource; +}): VisibilityAwarePollingFallback { + let stale = false; + let timer: unknown; + let unsubscribe: (() => void) | undefined; + const pollIfNeeded = () => { if (stale && options.visibility.isVisible()) options.poll(); }; + return { + start() { + if (timer !== undefined) return; + timer = options.scheduler.setInterval(pollIfNeeded, options.intervalMs); + unsubscribe = options.visibility.subscribe(pollIfNeeded); + }, + stop() { + if (timer !== undefined) options.scheduler.clearInterval(timer); + timer = undefined; + unsubscribe?.(); + unsubscribe = undefined; + }, + setStale(value) { stale = value; }, + }; +} + +export const browserVisibilitySource: VisibilitySource = { + isVisible: () => document.visibilityState === 'visible', + subscribe(listener) { + document.addEventListener('visibilitychange', listener); + return () => document.removeEventListener('visibilitychange', listener); + }, +}; + +export const browserPollingScheduler: PollingScheduler = { + setInterval: (callback, intervalMs) => window.setInterval(callback, intervalMs), + clearInterval: (handle) => { if (typeof handle === 'number') window.clearInterval(handle); }, +}; diff --git a/src/web/events/event-adapters.ts b/src/web/events/event-adapters.ts new file mode 100644 index 0000000..32dfa3a --- /dev/null +++ b/src/web/events/event-adapters.ts @@ -0,0 +1,51 @@ +import type { RealtimeEvent } from './event-types.js'; +import type { DashboardEventPublisher } from './event-publisher.js'; + +type Published = RealtimeEvent; +type SyncInput = + | { phase: 'started'; id: string; kind: 'delta' | 'full' | 'media' } + | { phase: 'progress'; id: string; kind: 'delta' | 'full' | 'media'; current: number; total?: number; label?: string } + | { phase: 'completed'; id: string; kind: 'delta' | 'full' | 'media'; processed: number } + | { phase: 'failed'; id: string; kind: 'delta' | 'full' | 'media'; error: string; code?: string }; + +const syncKind = (kind: SyncInput['kind']) => kind === 'delta' ? 'conversations' as const : kind; +const errorReason = (error: unknown) => error instanceof Error ? error.message : String(error); + +export function createDashboardEventAdapters(hub: DashboardEventPublisher) { + return { + bot(input: { running: boolean; dryRun: boolean; transitioning?: 'starting' | 'stopping'; reason?: string }): Published { + return hub.publish('bot.status.changed', { status: input.transitioning ?? (input.running ? 'running' : 'stopped'), dryRun: input.dryRun, reason: input.reason }); + }, + messageSent(input: { id: string; username: string; conversationId?: string }): Published { + return hub.publish('message.sent', { messageId: input.id, recipient: input.username, conversationId: input.conversationId }); + }, + messageFailed(input: { username: string; error: unknown; messageId?: string; conversationId?: string; code?: string; retryable?: boolean }): Published { + return hub.publish('message.failed', { recipient: input.username, reason: errorReason(input.error), messageId: input.messageId, conversationId: input.conversationId, code: input.code, retryable: input.retryable }); + }, + conversation(input: { id: string; state: 'new' | 'awaiting_reply' | 'replied' | 'closed'; unread?: number }): Published { + return hub.publish('conversation.updated', { conversationId: input.id, status: input.state === 'awaiting_reply' ? 'awaiting-reply' : input.state, unreadCount: input.unread }); + }, + reply(input: { conversationId: string; id: string; username: string; body?: string }): Published { + return hub.publish('reply.received', { conversationId: input.conversationId, replyId: input.id, sender: input.username, preview: input.body }); + }, + sync(input: SyncInput): Published { + const kind = syncKind(input.kind); + switch (input.phase) { + case 'started': return hub.publish('sync.started', { syncId: input.id, kind }); + case 'progress': return hub.publish('sync.progress', { syncId: input.id, kind, completed: input.current, total: input.total, message: input.label }); + case 'completed': return hub.publish('sync.completed', { syncId: input.id, kind, processed: input.processed }); + case 'failed': return hub.publish('sync.failed', { syncId: input.id, kind, reason: input.error, code: input.code }); + } + }, + rateLimit(input: { blocked: boolean; remaining?: number; resetAt?: number; reason?: string }): Published { + return hub.publish('rate-limit.changed', { limited: input.blocked, remaining: input.remaining, resetsAt: input.resetAt === undefined ? undefined : new Date(input.resetAt).toISOString(), reason: input.reason }); + }, + redditSession(input: { state: 'unknown' | 'authenticating' | 'authenticated' | 'expired' | 'login-required' | 'unreachable'; username?: string; reason?: string }): Published { + const status = input.state === 'login-required' ? 'logged-out' : input.state === 'unreachable' ? 'error' : input.state; + return hub.publish('reddit.session.changed', { status, username: input.username, reason: input.reason }); + }, + log(input: { level: 'debug' | 'info' | 'warn' | 'error'; message: string; data?: unknown }): Published { + return hub.publish('log.created', input); + }, + }; +} diff --git a/src/web/events/event-publisher.ts b/src/web/events/event-publisher.ts new file mode 100644 index 0000000..f70c9ef --- /dev/null +++ b/src/web/events/event-publisher.ts @@ -0,0 +1,10 @@ +import type { RealtimeEvent, RealtimeEventMap, RealtimeEventType } from './event-types.js'; +import type { RealtimeEventHub } from './realtime-event-hub.js'; + +export interface DashboardEventPublisher { + publish(type: TType, payload: RealtimeEventMap[TType]): RealtimeEvent; +} + +export function createDashboardEventPublisher(hub: RealtimeEventHub): DashboardEventPublisher { + return { publish: (type, payload) => hub.publish(type, payload) }; +} diff --git a/src/web/events/index.ts b/src/web/events/index.ts index c41cc1e..3439da1 100644 --- a/src/web/events/index.ts +++ b/src/web/events/index.ts @@ -21,3 +21,5 @@ export { type RealtimeEventSubscription, type SubscribeOptions, } from './realtime-event-hub.js'; +export { createDashboardEventAdapters } from './event-adapters.js'; +export { createDashboardEventPublisher, type DashboardEventPublisher } from './event-publisher.js'; diff --git a/tests/dashboard/realtime-client.test.ts b/tests/dashboard/realtime-client.test.ts index 6003141..528b897 100644 --- a/tests/dashboard/realtime-client.test.ts +++ b/tests/dashboard/realtime-client.test.ts @@ -57,6 +57,24 @@ describe('dashboard realtime client', () => { expect(client.getSnapshot()).toMatchObject({ status: 'closed', isStale: true }); }); + it('requests an authoritative repair on initial connection and every reconnect', () => { + const source = new FakeEventSource(); + const connected = vi.fn(); + const client = createDashboardRealtimeClient({ + sourceFactory: () => source, + onEvent: vi.fn(), + onConnected: connected, + }); + + client.start(); + source.emit('open'); + source.emit('error'); + source.emit('open'); + + expect(connected).toHaveBeenNthCalledWith(1, { isReconnect: false }); + expect(connected).toHaveBeenNthCalledWith(2, { isReconnect: true }); + }); + it('maps events to the queries whose authoritative snapshots need refresh', () => { expect( queryKeysForDashboardEvent({ diff --git a/tests/dashboard/realtime-fallback.test.ts b/tests/dashboard/realtime-fallback.test.ts new file mode 100644 index 0000000..1cc45d4 --- /dev/null +++ b/tests/dashboard/realtime-fallback.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createVisibilityAwarePollingFallback } from '../../apps/dashboard/src/features/operations/realtime-fallback.js'; + +describe('visibility-aware realtime fallback', () => { + it('polls only while stale and visible, including immediately on returning visible', () => { + let visible = false; + let interval: (() => void) | undefined; + let visibilityListener: (() => void) | undefined; + const poll = vi.fn(); + const fallback = createVisibilityAwarePollingFallback({ + intervalMs: 5_000, + poll, + scheduler: { + setInterval(callback) { interval = callback; return 'timer'; }, + clearInterval: vi.fn(), + }, + visibility: { + isVisible: () => visible, + subscribe(listener) { visibilityListener = listener; return vi.fn(); }, + }, + }); + + fallback.start(); + fallback.setStale(true); + interval?.(); + expect(poll).not.toHaveBeenCalled(); + + visible = true; + visibilityListener?.(); + expect(poll).toHaveBeenCalledOnce(); + interval?.(); + expect(poll).toHaveBeenCalledTimes(2); + + fallback.setStale(false); + interval?.(); + expect(poll).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/dashboard/realtime-repair.test.ts b/tests/dashboard/realtime-repair.test.ts new file mode 100644 index 0000000..27b8ba1 --- /dev/null +++ b/tests/dashboard/realtime-repair.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it, vi } from 'vitest'; +import { repairRealtimeSnapshots } from '../../apps/dashboard/src/app/realtime-repair.js'; + +describe('authoritative realtime repair', () => { + it('marks snapshots stale and explicitly refetches active observers', async () => { + const invalidateQueries = vi.fn().mockResolvedValue(undefined); + const refetchQueries = vi.fn().mockResolvedValue(undefined); + await repairRealtimeSnapshots({ invalidateQueries, refetchQueries }); + expect(invalidateQueries).toHaveBeenCalled(); + expect(refetchQueries).toHaveBeenCalled(); + expect(refetchQueries).toHaveBeenCalledWith(expect.objectContaining({ type: 'active' })); + }); +}); diff --git a/tests/dashboard/route-error-boundary.test.tsx b/tests/dashboard/route-error-boundary.test.tsx new file mode 100644 index 0000000..e3f9d23 --- /dev/null +++ b/tests/dashboard/route-error-boundary.test.tsx @@ -0,0 +1,10 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { createDashboardRoutes } from '../../apps/dashboard/src/app/router.js'; + +describe('dashboard route error boundary', () => { + it('protects the application shell from route rendering failures', () => { + const root = createDashboardRoutes()[0]; + expect(root?.errorElement).toBeTruthy(); + }); +}); diff --git a/tests/dashboard/shell-status.test.ts b/tests/dashboard/shell-status.test.ts new file mode 100644 index 0000000..fe9e480 --- /dev/null +++ b/tests/dashboard/shell-status.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { deriveShellStatus } from '../../apps/dashboard/src/app/shell-status.js'; + +describe('global shell status', () => { + it('derives safety-critical labels from authoritative snapshots', () => { + expect(deriveShellStatus({ + stats: { isRunning: true, dryRunMode: true, invitationCooldown: { reason: 'Reddit cooldown' } }, + reddit: { ready: true, loggedIn: true, status: 'authenticated' }, + realtime: { status: 'reconnecting', isStale: true }, + sync: { syncing: true, syncingKind: 'full' }, + })).toEqual({ + bot: 'Running (dry run)', reddit: 'Reddit authenticated', realtime: 'Realtime reconnecting (stale)', + cooldown: 'Reddit cooldown', sync: 'Full sync active', controlAction: 'stop', controlDisabled: false, + }); + }); + + it('guards bot controls until the authoritative bot state is available', () => { + expect(deriveShellStatus({ realtime: { status: 'idle', isStale: true } })).toMatchObject({ + bot: 'Bot state unavailable', controlDisabled: true, + }); + }); +}); diff --git a/tests/web/events/event-adapters.test.ts b/tests/web/events/event-adapters.test.ts new file mode 100644 index 0000000..39a0ba6 --- /dev/null +++ b/tests/web/events/event-adapters.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { RealtimeEventHub } from '../../../src/web/events/realtime-event-hub.js'; +import { createDashboardEventAdapters } from '../../../src/web/events/event-adapters.js'; +import { createDashboardEventPublisher } from '../../../src/web/events/event-publisher.js'; + +describe('dashboard event adapters', () => { + it('provides a transport-neutral typed publisher over the event hub', () => { + const hub = new RealtimeEventHub({ capacity: 2 }); + const publisher = createDashboardEventPublisher(hub); + const event = publisher.publish('bot.status.changed', { status: 'running', dryRun: false }); + expect(event.type).toBe('bot.status.changed'); + expect(hub.recent()).toEqual([event]); + }); + it('translates every route and service event family to typed dashboard events', () => { + const hub = new RealtimeEventHub({ capacity: 20 }); + const adapter = createDashboardEventAdapters(hub); + adapter.bot({ running: true, dryRun: true }); + adapter.messageSent({ id: 'm1', username: 'alice', conversationId: 'c1' }); + adapter.messageFailed({ username: 'bob', error: new Error('blocked'), retryable: false }); + adapter.conversation({ id: 'c1', state: 'awaiting_reply', unread: 2 }); + adapter.reply({ conversationId: 'c1', id: 'r1', username: 'alice', body: 'hello' }); + adapter.sync({ phase: 'started', id: 's1', kind: 'delta' }); + adapter.sync({ phase: 'progress', id: 's1', kind: 'delta', current: 2, total: 3 }); + adapter.sync({ phase: 'completed', id: 's1', kind: 'delta', processed: 3 }); + adapter.sync({ phase: 'failed', id: 's2', kind: 'media', error: 'network' }); + adapter.rateLimit({ blocked: true, remaining: 0, resetAt: 1_786_176_000_000 }); + adapter.redditSession({ state: 'login-required' }); + adapter.log({ level: 'warn', message: 'careful' }); + + expect(hub.recent().map((event) => event.type)).toEqual([ + 'bot.status.changed', 'message.sent', 'message.failed', 'conversation.updated', 'reply.received', + 'sync.started', 'sync.progress', 'sync.completed', 'sync.failed', 'rate-limit.changed', + 'reddit.session.changed', 'log.created', + ]); + expect(hub.recent()[2]?.payload).toMatchObject({ recipient: 'bob', reason: 'blocked' }); + expect(hub.recent()[3]?.payload).toMatchObject({ status: 'awaiting-reply', unreadCount: 2 }); + expect(hub.recent()[10]?.payload).toEqual({ status: 'logged-out' }); + }); +});