// @vitest-environment jsdom import '@testing-library/jest-dom/vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { HttpResponse, http } from 'msw'; import { setupServer } from 'msw/node'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import { createDashboardRoutes } from '../../apps/dashboard/src/app/router.js'; interface TestStatsOverrides { isRunning?: boolean; recentActivity?: readonly unknown[]; accounts?: readonly unknown[]; } const server = setupServer(); function createStats(overrides: TestStatsOverrides = {}) { return { isRunning: overrides.isRunning ?? false, dryRunMode: true, totalProcessed: 18, totalMessagesSent: 12, totalResponses: 3, responseRate: 25, messagesThisHour: 2, maxMessagesPerHour: 8, subreddits: [{ name: 'casualconversation', type: 'chat' }], targetGenders: ['female'], recentActivity: overrides.recentActivity ?? [], accounts: overrides.accounts ?? [], invitationCooldown: null, }; } function installSuccessfulApi(overrides: TestStatsOverrides = {}) { server.use( http.get('/api/stats', () => HttpResponse.json(createStats(overrides))), http.get('/api/timers', () => HttpResponse.json({ nextMessageAvailableAt: null, hourlyResetAt: Date.now() + 3_600_000, isHourlyLimitReached: false, messagesRemaining: 6, }), ), http.get('/api/messages', () => HttpResponse.json([])), ); } function renderDashboard(initialPath = '/') { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); const router = createMemoryRouter(createDashboardRoutes(), { initialEntries: [initialPath] }); render( , ); return { queryClient, router }; } beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => { cleanup(); server.resetHandlers(); vi.restoreAllMocks(); }); afterAll(() => server.close()); describe('dashboard foundation', () => { it('provides responsive navigation with real routes for every platform area', async () => { installSuccessfulApi(); const user = userEvent.setup(); const { router } = renderDashboard('/'); const navigation = screen.getByRole('navigation', { name: 'Primary navigation' }); for (const label of ['Overview', 'Needs attention', 'Outreach funnel', 'Conversations', 'Media', 'Performance', 'Operations', 'Settings']) { expect(within(navigation).getByRole('link', { name: label })).toBeInTheDocument(); } await user.click(screen.getByRole('button', { name: 'Open navigation' })); expect(screen.getByRole('button', { name: 'Close navigation' })).toHaveAttribute('aria-expanded', 'true'); await user.click(within(navigation).getByRole('link', { name: 'Operations' })); await waitFor(() => expect(router.state.location.pathname).toBe('/operations')); expect(screen.getByRole('heading', { name: 'Operations' })).toBeInTheDocument(); }); it('shows actionable attention items and links handoffs to their conversation', async () => { installSuccessfulApi(); server.use(http.get('/api/attention', () => HttpResponse.json({ generatedAt: Date.parse('2026-08-09T08:00:00Z'), items: [{ id: 'conversation:one', kind: 'human-required', severity: 'warning', title: 'Human follow-up required for u/alice', detail: 'Automated turns are exhausted.', occurredAt: Date.parse('2026-08-09T07:00:00Z'), owner: { accountId: '@one:reddit.com', redditUsername: 'operator' }, conversationId: '@one:reddit.com~!room:reddit.com', username: 'alice', }], }))); renderDashboard('/attention'); expect(await screen.findByRole('heading', { name: 'Needs attention' })).toBeInTheDocument(); expect(screen.getByText('Human follow-up required for u/alice')).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Open conversation' })).toHaveAttribute('href', '/conversations/%40one%3Areddit.com~!room%3Areddit.com'); }); it('shows the live outreach stages and the potential posts being burned down', async () => { installSuccessfulApi(); const user = userEvent.setup(); server.use( http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: true, status: 'authenticated' })), http.get('/api/chats/sync', () => HttpResponse.json({ syncing: false, syncingKind: null, lastFullSyncAt: null, lastDeltaSyncAt: null, hasEverFullSynced: false, cachedChats: 0, progress: null, })), http.get('/api/outreach', () => HttpResponse.json({ generatedAt: Date.parse('2026-08-09T08:00:00Z'), physiqueTargeting: { enabled: true, minScore: 1, maxScore: 6 }, activeCount: 3, stages: { discovered: 1, screening: 0, ready: 1, sending: 0, retryable: 1 }, outcomes: { sent: 12, rejected: 7 }, oldestActiveAt: Date.parse('2026-08-09T07:00:00Z'), candidates: [{ postId: 'candidate-1', subreddit: 'example', author: 'alice', title: '[25F] A potential post', postUrl: 'https://www.reddit.com/r/example/comments/candidate-1/', sourceType: 'image', status: 'ready', priority: 2, postCreatedAt: Date.parse('2026-08-09T06:50:00Z'), discoveredAt: Date.parse('2026-08-09T07:00:00Z'), updatedAt: Date.parse('2026-08-09T07:30:00Z'), availableAt: Date.parse('2026-08-09T07:30:00Z'), attemptCount: 1, hasPreparedMessage: true, physiqueScore: 6, preparation: { message: 'You look incredible in that pose — what inspired this post?', imageAnalysis: 'VISIBLE_FACTS:\nBODY: VISIBLE - full-body pose', detectedGender: 'female', attractivenessScore: 68, physiqueScore: 6, physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 }, sourceImageUrl: 'https://i.redd.it/candidate-1.jpg', strategyId: 'direct-observation', experimentId: 'strategies:direct-observation:abc123', promptVersion: 'abc123', selectionReason: 'exploit', modelId: 'example/image-model', toneId: 'direct', scripted: false, }, note: null, owner: null, }], })), ); renderDashboard('/outreach'); expect(await screen.findByRole('heading', { name: 'Outreach funnel' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '3 active posts' })).toBeInTheDocument(); expect(screen.getByRole('list', { name: 'Active outreach candidates' })).toHaveTextContent('A potential post'); expect(screen.getAllByRole('link', { name: '[25F] A potential post' })) .toEqual(expect.arrayContaining([ expect.objectContaining({ href: 'https://www.reddit.com/r/example/comments/candidate-1/' }), ])); expect(within(screen.getByRole('list', { name: 'Active outreach candidates' })).getByText('Message prepared')).toBeInTheDocument(); const preparation = screen.getByRole('region', { name: 'Prepared outreach for u/alice' }); expect(preparation).toHaveTextContent('You look incredible in that pose'); expect(preparation).toHaveTextContent('68/100'); expect(preparation).toHaveTextContent('6/10'); expect(preparation).toHaveTextContent('Current target 1–6 · Prepared with filter off'); expect(preparation).toHaveTextContent('example/image-model'); const sourceImage = within(preparation).getByRole('img', { name: 'Source post: [25F] A potential post' }); expect(sourceImage).toHaveClass('is-blurred'); await user.click(within(preparation).getByRole('button', { name: 'Reveal source image: [25F] A potential post' })); expect(sourceImage).not.toHaveClass('is-blurred'); }); it('hydrates media filters from the routed URL', async () => { installSuccessfulApi(); server.use( http.get('/api/media', () => HttpResponse.json([{ id: 'media-1', chatUsername: 'alice', chatRoomId: 'room-1', sender: 'alice', timestamp: Date.now(), originalUrl: 'https://example.test/reaction.gif', fileName: 'reaction.gif', relativePath: 'reaction.gif', createdAt: Date.now(), urlPath: '/media/reaction.gif', }])), http.get('/api/media/sync', () => HttpResponse.json({ isSyncing: false, lastSyncAt: null, lastSyncDurationMs: null, lastSyncChats: null, lastError: null, nextSyncAt: null, })), http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: true, status: 'authenticated', })), http.get('/api/chats/sync', () => HttpResponse.json({ syncing: false, syncingKind: null, lastFullSyncAt: null, lastDeltaSyncAt: null, hasEverFullSynced: false, cachedChats: 0, progress: null, })), ); renderDashboard('/media?conversation=alice&type=gif&range=7d'); expect(await screen.findByRole('combobox', { name: 'Conversation' })).toHaveValue('alice'); expect(screen.getByRole('combobox', { name: 'Media type' })).toHaveValue('gif'); expect(screen.getByRole('combobox', { name: 'Date range' })).toHaveValue('7d'); }); it('shows loading then renders stats, timers, and an explicit empty activity state', async () => { let resolveStats: ((response: Response) => void) | undefined; server.use( http.get('/api/stats', async () => new Promise((resolve) => { resolveStats = resolve; }), ), http.get('/api/timers', () => HttpResponse.json({ nextMessageAvailableAt: null, hourlyResetAt: Date.now() + 3_600_000, isHourlyLimitReached: false, messagesRemaining: 6, }), ), http.get('/api/messages', () => HttpResponse.json([{ id: 1, postId: 'post-1', recipient: 'alice', subject: 'Hello', message: 'A recent opener', sentAt: Date.now(), gotResponse: true, gotImage: false, responseCheckedAt: Date.now(), strategyId: null, chatRoomId: null, }])), ); renderDashboard(); expect(screen.getByRole('status', { name: 'Loading overview' })).toBeInTheDocument(); await waitFor(() => expect(resolveStats).toBeTypeOf('function')); resolveStats?.(HttpResponse.json(createStats())); expect(await screen.findByText('12', { selector: '[data-metric="messages-sent"]' })).toBeInTheDocument(); expect(screen.getByText('6 messages remaining')).toBeInTheDocument(); expect(screen.getByText('No recent activity yet.')).toBeInTheDocument(); expect(screen.getByText('Dry run')).toBeInTheDocument(); expect(screen.getByText('No accounts configured.')).toBeInTheDocument(); expect(await screen.findByRole('list', { name: 'Recent sent messages' })).toHaveTextContent('A recent opener'); expect(screen.getByRole('list', { name: 'Recent sent messages' })).toHaveTextContent('Responded'); expect(screen.getByRole('link', { name: 'u/alice' })).toHaveAttribute('href', 'https://www.reddit.com/u/alice'); }); it('shows the live sending status and cooldown for every account', async () => { const now = Date.now(); installSuccessfulApi({ isRunning: true, accounts: [ { accountId: '@t2_ready:reddit.com', redditUsername: 'ready-account', runtimeState: 'running', authenticated: true, sendingState: 'ready', messagesThisHour: 2, maxMessagesPerHour: 8, nextMessageAvailableAt: null, hourlyResetAt: now + 3_600_000, messagesRemaining: 6, actionableReplies: 0, invitationCooldown: null, failure: null, }, { accountId: '@t2_limited:reddit.com', redditUsername: 'limited-account', runtimeState: 'running', authenticated: true, sendingState: 'matrix-cooldown', messagesThisHour: 5, maxMessagesPerHour: 8, nextMessageAvailableAt: now + 600_000, hourlyResetAt: now + 3_600_000, messagesRemaining: 3, actionableReplies: 1, invitationCooldown: { until: now + 600_000, reason: 'Matrix rate-limited: M_LIMIT_EXCEEDED', redditCode: 'rate.invitation_limit_mature_account', }, failure: null, }, ], }); renderDashboard(); const panel = await screen.findByRole('region', { name: 'Account status' }); expect(within(panel).getByText('2 configured')).toBeInTheDocument(); expect(within(panel).getByRole('article', { name: 'Status for u/ready-account' })).toHaveTextContent('Ready'); const limited = within(panel).getByRole('article', { name: 'Status for u/limited-account' }); expect(limited).toHaveTextContent('Matrix cooldown'); expect(limited).toHaveTextContent('Matrix rate-limited: M_LIMIT_EXCEEDED'); expect(limited).toHaveTextContent('rate.invitation_limit_mature_account'); expect(limited).toHaveTextContent('5 of 8'); expect(limited).toHaveTextContent('Pending replies1'); }); it('keeps stats visible when timers fail and retries only the failed panel', async () => { let timerRequests = 0; server.use( http.get('/api/stats', () => HttpResponse.json(createStats())), http.get('/api/timers', () => { timerRequests += 1; if (timerRequests === 1) return HttpResponse.json({ error: 'Rate limiter not initialized' }, { status: 503 }); return HttpResponse.json({ nextMessageAvailableAt: null, hourlyResetAt: Date.now() + 3_600_000, isHourlyLimitReached: false, messagesRemaining: 6, }); }), ); const user = userEvent.setup(); renderDashboard(); expect(await screen.findByText('12', { selector: '[data-metric="messages-sent"]' })).toBeInTheDocument(); const alert = await screen.findByRole('alert'); expect(alert).toHaveTextContent('Rate limiter not initialized'); await user.click(within(alert).getByRole('button', { name: 'Retry timers' })); expect(await screen.findByText('6 messages remaining')).toBeInTheDocument(); expect(timerRequests).toBe(2); }); it('makes usernames in recent activity clickable', async () => { installSuccessfulApi({ recentActivity: [{ timestamp: Date.now(), type: 'response', message: 'Reply from u/alice', recipient: 'alice', }], }); renderDashboard(); const activity = await screen.findByRole('heading', { name: 'Recent activity' }); const panel = activity.closest('section'); expect(panel).not.toBeNull(); expect(within(panel!).getByRole('link', { name: 'u/alice' })).toHaveAttribute( 'href', 'https://www.reddit.com/u/alice', ); }); it('opens saved source-post context from a sent activity entry', async () => { installSuccessfulApi({ recentActivity: [{ timestamp: Date.now(), type: 'sent', message: 'Sent message to u/alice (r/example)', recipient: 'alice', imageUrl: 'https://i.redd.it/source.jpg', postTitle: 'The source post title', postUrl: 'https://www.reddit.com/r/example/comments/source/the_source_post_title/', }], }); const user = userEvent.setup(); renderDashboard(); await user.click(await screen.findByRole('button', { name: 'Sent message to u/alice (r/example)' })); const dialog = screen.getByRole('dialog', { name: 'The source post title' }); expect(within(dialog).getByRole('img', { name: 'Source post: The source post title' })).toHaveAttribute( 'src', 'https://i.redd.it/source.jpg', ); expect(within(dialog).getByRole('link', { name: /Open Reddit post/ })).toHaveAttribute( 'href', 'https://www.reddit.com/r/example/comments/source/the_source_post_title/', ); }); it('styles the recent-messages retry control when its request fails', async () => { installSuccessfulApi(); server.use(http.get('/api/messages', () => HttpResponse.json({ error: 'Messages unavailable' }, { status: 503 }))); renderDashboard(); const retryButton = await screen.findByRole('button', { name: 'Retry messages' }); expect(retryButton).toHaveClass('button', 'secondary'); }); it('shows refresh progress while preserving the current overview', async () => { installSuccessfulApi(); const user = userEvent.setup(); renderDashboard(); const refreshButton = await screen.findByRole('button', { name: 'Refresh overview' }); let resolveRefresh: ((response: Response) => void) | undefined; server.use( http.get('/api/stats', async () => new Promise((resolve) => { resolveRefresh = resolve; }), ), ); await user.click(refreshButton); expect(screen.getByRole('button', { name: 'Refreshing overview' })).toBeDisabled(); expect(screen.getByText('12', { selector: '[data-metric="messages-sent"]' })).toBeInTheDocument(); resolveRefresh?.(HttpResponse.json(createStats())); await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh overview' })).toBeEnabled()); }); it('requires confirmation before starting and reflects the authoritative response', async () => { installSuccessfulApi(); let startRequests = 0; server.use( http.post('/api/start', () => { startRequests += 1; return HttpResponse.json({ success: true, running: true }); }), ); const user = userEvent.setup(); renderDashboard(); await user.click(await screen.findByRole('button', { name: 'Start bot' })); const dialog = screen.getByRole('dialog', { name: 'Start bot?' }); expect(dialog).toHaveTextContent('The bot will resume automated processing.'); await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); expect(startRequests).toBe(0); await user.click(screen.getByRole('button', { name: 'Start bot' })); await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm start' })); expect(await screen.findByText('Running', { selector: '[data-bot-status]' })).toBeInTheDocument(); expect(startRequests).toBe(1); }); it('shows a start failure inside the confirmation dialog instead of a stray toast', async () => { installSuccessfulApi(); server.use( http.post('/api/start', () => HttpResponse.json({ error: 'Browser runtime is not ready' }, { status: 503 })), ); const user = userEvent.setup(); renderDashboard(); await user.click(await screen.findByRole('button', { name: 'Start bot' })); const dialog = screen.getByRole('dialog', { name: 'Start bot?' }); await user.click(within(dialog).getByRole('button', { name: 'Confirm start' })); expect(await within(dialog).findByRole('alert')).toHaveTextContent('Browser runtime is not ready'); // The dialog stays open so the operator can retry or cancel. expect(screen.getByRole('dialog', { name: 'Start bot?' })).toBeInTheDocument(); await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('flips the sending window from cooling down to ready without a refetch', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); try { let timerRequests = 0; const startedAt = Date.now(); server.use( http.get('/api/stats', () => HttpResponse.json(createStats())), http.get('/api/timers', () => { timerRequests += 1; return HttpResponse.json({ nextMessageAvailableAt: startedAt + 5_000, hourlyResetAt: startedAt + 3_600_000, isHourlyLimitReached: false, messagesRemaining: 6, }); }), http.get('/api/messages', () => HttpResponse.json([])), ); renderDashboard(); expect(await screen.findByText('Cooling down')).toBeInTheDocument(); expect(screen.getByText(/^Ready in /)).toBeInTheDocument(); const requestsAfterLoad = timerRequests; act(() => { vi.advanceTimersByTime(6_000); }); expect(screen.getByText('Ready')).toBeInTheDocument(); expect(screen.getByText('Available now')).toBeInTheDocument(); expect(screen.queryByText('Cooling down')).not.toBeInTheDocument(); expect(timerRequests).toBe(requestsAfterLoad); } finally { vi.useRealTimers(); } }); });