Files
RedditBot/tests/dashboard/performance-operations.test.tsx
T
Jamie BruntonandClaude Fable 5 e984bf20ac feat: dashboard UI/UX overhaul from full review
Shared foundation:
- New Modal component on native <dialog>.showModal() (focus trap, inert
  background, Escape, focus restore, scroll lock); all eight dialogs and
  both globalThis.confirm calls migrated to it
- Design tokens for success/warning/danger surfaces; pill danger variant;
  .sr-only utility; toast layering above dialog backdrops; not-allowed
  cursor for validity-disabled buttons

Conversations/attention: auto-scroll with jump-to-latest, per-conversation
draft persistence, suggestion undo, 30s auto-refresh with refresh controls,
unread emphasis and counts, blur-consistent share dialog, empty-accounts
guidance, collapsed user lookup, relative timestamps, danger pills for
critical items

Media/library/outreach: click-blurred-to-reveal, lightbox with arrow
navigation and captions, dirty-form preservation across refetches, upload
validation with thumbnail preview, save feedback, result counts with
load-more and clear-filters, ticking countdowns from a single clock

Overview/performance/operations/settings: mutation errors inside the
confirm dialog, live sending-window countdown, offline-aware loading
states, per-panel loading/error with retry, 7/30/90-day range toggle,
console scroll yielding with shown/retained counts, risky-save matching
by subreddit name instead of index, useBlocker unsaved-changes guard,
single-pass validation with focus-to-first-error, access panel JSON
validation and input reset

Tests: 806 -> 844 passing; both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 09:07:42 +01:00

311 lines
16 KiB
TypeScript

// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import type { PropsWithChildren, ReactElement } from 'react';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { OperationsPage } from '../../apps/dashboard/src/features/operations/operations-page.js';
import { PerformancePage } from '../../apps/dashboard/src/features/performance/performance-page.js';
const server = setupServer();
function TestProviders({ children }: PropsWithChildren) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
function renderPage(element: ReactElement) {
return render(element, { wrapper: TestProviders });
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
describe('PerformancePage', () => {
it('shows sample sizes, confidence warnings, useful comparisons, and successful examples', async () => {
server.use(
http.get('/api/analytics/by-subreddit', () =>
HttpResponse.json([
{ subreddit: 'askuk', totalSent: 40, totalResponses: 12, responseRate: 30, totalImages: 6, imageRate: 15 },
{ subreddit: 'tiny', totalSent: 3, totalResponses: 2, responseRate: 67, totalImages: 1, imageRate: 33 },
{ subreddit: 'pending-only', totalSent: 0, totalResponses: 0, responseRate: 0, totalImages: 0, imageRate: 0, pendingSent: 9, responseRateLow: 0, responseRateHigh: 100 },
]),
),
http.get('/api/analytics/by-strategy', () =>
HttpResponse.json([
{ strategyId: 'friendly', totalSent: 25, totalResponses: 10, responseRate: 40, totalImages: 5, imageRate: 20, configured: true },
{ strategyId: 'new-test', totalSent: 2, totalResponses: 1, responseRate: 50, totalImages: 1, imageRate: 50 },
{ strategyId: 'pending-test', totalSent: 0, totalResponses: 0, responseRate: 0, totalImages: 0, imageRate: 0, pendingSent: 7, responseRateLow: 0, responseRateHigh: 100, configured: true },
]),
),
http.get('/api/analytics/trend', ({ request }) => {
expect(new URL(request.url).searchParams.get('days')).toBe('30');
return HttpResponse.json([
{ date: '2026-08-07', totalSent: 10, totalResponses: 2, responseRate: 20, totalImages: 1, imageRate: 10 },
{ date: '2026-08-08', totalSent: 8, totalResponses: 4, responseRate: 50, totalImages: 2, imageRate: 25 },
]);
}),
http.get('/api/successful', () =>
HttpResponse.json([
{
id: 7,
sentMessageId: 22,
originalMessage: 'A thoughtful opener',
imageAnalysis: null,
postTitle: 'Weekend plans',
responseSnippet: 'That sounds lovely!',
subreddit: 'askuk',
createdAt: Date.parse('2026-08-08T10:00:00Z'),
gotImage: true,
},
]),
),
http.get('/api/analytics/funnel', () => HttpResponse.json({
responseWindowHours: 48,
processed: 100,
sent: 40,
pending: 5,
mature: 35,
responded: 12,
engaged: 4,
declined: 2,
boundary: 1,
upsell: 3,
images: 2,
})),
http.get('/api/analytics/ai-usage', ({ request }) => {
expect(new URL(request.url).searchParams.get('days')).toBe('30');
return HttpResponse.json({
days: 30, requests: 12, successes: 9, refusals: 2, errors: 1,
promptTokens: 1200, completionTokens: 300, totalTokens: 1500,
costUsd: 0.0123, averageLatencyMs: 420,
models: [{ model: 'model-a', requests: 12, successes: 9, refusals: 2, errors: 1, promptTokens: 1200, completionTokens: 300, totalTokens: 1500, costUsd: 0.0123, averageLatencyMs: 420 }],
});
}),
);
renderPage(<PerformancePage />);
expect(await screen.findByRole('heading', { name: 'Performance' })).toBeInTheDocument();
expect(await screen.findByRole('heading', { name: 'Outcome funnel' })).toBeInTheDocument();
expect(await screen.findByRole('heading', { name: 'AI cost and reliability' })).toBeInTheDocument();
expect(screen.getByRole('table', { name: 'AI usage by model' })).toHaveTextContent('model-a');
expect(screen.getByLabelText('AI usage totals')).toHaveTextContent('$0.0123');
expect(screen.getByText('5 awaiting maturity')).toBeInTheDocument();
const subredditTable = await screen.findByRole('table', { name: 'Subreddit performance' });
expect(within(subredditTable).getByText('n = 40')).toBeInTheDocument();
expect(within(subredditTable).getByText('n = 3')).toBeInTheDocument();
expect(within(subredditTable).getAllByText('Low confidence')).toHaveLength(2);
expect(within(subredditTable).getByRole('columnheader', { name: 'Images' })).toBeInTheDocument();
expect(within(subredditTable).getByText('15%')).toBeInTheDocument();
expect(within(subredditTable).getByText('9 pending')).toBeInTheDocument();
const strategyTable = await screen.findByRole('table', { name: 'Strategy performance' });
expect(within(strategyTable).getByText('n = 25')).toBeInTheDocument();
expect(within(strategyTable).getAllByText('Configured')).toHaveLength(2);
expect(within(strategyTable).getByText('7 pending')).toBeInTheDocument();
expect(await screen.findByRole('list', { name: '30 day response trend' })).toHaveTextContent('50%');
expect(await screen.findByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('A thoughtful opener');
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('That sounds lovely!');
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('Image received');
});
it('renders each panel independently and retries only the failed panel', async () => {
let funnelRequests = 0;
server.use(
http.get('/api/analytics/by-subreddit', () =>
HttpResponse.json([
{ subreddit: 'askuk', totalSent: 40, totalResponses: 12, responseRate: 30, totalImages: 6, imageRate: 15 },
]),
),
http.get('/api/analytics/by-strategy', () => HttpResponse.json([])),
http.get('/api/analytics/trend', () => HttpResponse.json([])),
http.get('/api/successful', () => HttpResponse.json([])),
http.get('/api/analytics/funnel', () => {
funnelRequests += 1;
if (funnelRequests === 1) return HttpResponse.json({ error: 'Funnel unavailable right now' }, { status: 503 });
return HttpResponse.json({
responseWindowHours: 48, processed: 100, sent: 40, pending: 5, mature: 35,
responded: 12, engaged: 4, declined: 2, boundary: 1, upsell: 3, images: 2,
});
}),
http.get('/api/analytics/ai-usage', () => HttpResponse.json({
days: 30, requests: 0, successes: 0, refusals: 0, errors: 0,
promptTokens: 0, completionTokens: 0, totalTokens: 0, costUsd: 0, averageLatencyMs: 0, models: [],
})),
);
const user = userEvent.setup();
renderPage(<PerformancePage />);
// The failed funnel query renders its own inline error…
const funnelError = await screen.findByRole('alert');
expect(funnelError).toHaveTextContent('Funnel unavailable right now');
// …while sibling panels still render their data and empty states.
expect(await screen.findByRole('table', { name: 'Subreddit performance' })).toHaveTextContent('askuk');
expect(screen.getAllByText('No sends recorded yet.').length).toBeGreaterThanOrEqual(2);
await user.click(within(funnelError).getByRole('button', { name: 'Retry outcome funnel' }));
expect(await screen.findByRole('heading', { name: 'Outcome funnel' })).toBeInTheDocument();
expect(screen.getByText('5 awaiting maturity')).toBeInTheDocument();
expect(funnelRequests).toBe(2);
});
it('switches the trend and AI usage range with the day toggle', async () => {
const trendDays: Array<string | null> = [];
const usageDays: Array<string | null> = [];
server.use(
http.get('/api/analytics/by-subreddit', () => HttpResponse.json([])),
http.get('/api/analytics/by-strategy', () => HttpResponse.json([])),
http.get('/api/successful', () => HttpResponse.json([])),
http.get('/api/analytics/funnel', () => HttpResponse.json({
responseWindowHours: 48, processed: 0, sent: 0, pending: 0, mature: 0,
responded: 0, engaged: 0, declined: 0, boundary: 0, upsell: 0, images: 0,
})),
http.get('/api/analytics/trend', ({ request }) => {
trendDays.push(new URL(request.url).searchParams.get('days'));
return HttpResponse.json([
{ date: '2026-08-07', totalSent: 10, totalResponses: 2, responseRate: 20, totalImages: 1, imageRate: 10 },
]);
}),
http.get('/api/analytics/ai-usage', ({ request }) => {
usageDays.push(new URL(request.url).searchParams.get('days'));
return HttpResponse.json({
days: 7, requests: 0, successes: 0, refusals: 0, errors: 0,
promptTokens: 0, completionTokens: 0, totalTokens: 0, costUsd: 0, averageLatencyMs: 0, models: [],
});
}),
);
const user = userEvent.setup();
renderPage(<PerformancePage />);
expect(await screen.findByRole('list', { name: '30 day response trend' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '7 days' }));
expect(await screen.findByRole('list', { name: '7 day response trend' })).toBeInTheDocument();
expect(trendDays).toEqual(['30', '7']);
expect(usageDays).toEqual(['30', '7']);
});
});
describe('OperationsPage', () => {
it('keeps readable activity, job state, and raw searchable logs separate', async () => {
server.use(
http.get('/api/activity', () =>
HttpResponse.json([
{ timestamp: Date.parse('2026-08-08T12:00:00Z'), type: 'response', message: 'Reply from u/alex', recipient: 'alex' },
]),
),
http.get('/api/logs', ({ request }) => {
expect(new URL(request.url).searchParams.get('limit')).toBe('200');
return HttpResponse.json([
{ timestamp: Date.parse('2026-08-08T12:00:01Z'), level: 'info', message: 'Sync complete' },
{
timestamp: Date.parse('2026-08-08T12:00:02Z'),
level: 'error',
message: 'Matrix request failed: M_LIMIT_EXCEEDED',
data: { accountId: '@t2_owner:reddit.com', redditUsername: 'operator' },
},
]);
}),
http.get('/api/chats/sync', () =>
HttpResponse.json({
syncing: true,
syncingKind: 'delta',
lastFullSyncAt: null,
lastDeltaSyncAt: null,
hasEverFullSynced: false,
cachedChats: 18,
progress: {
kind: 'delta',
phase: 'loading-details',
current: 4,
total: 10,
label: 'Loading room history',
startedAt: Date.parse('2026-08-08T11:59:00Z'),
},
}),
),
);
const user = userEvent.setup();
renderPage(<OperationsPage realtimeEnabled={false} />);
expect(await screen.findByRole('heading', { name: 'Operations' })).toBeInTheDocument();
const activityRegion = screen.getByRole('region', { name: 'Activity' });
expect(await within(activityRegion).findByRole('link', { name: 'u/alex' })).toBeInTheDocument();
expect(within(activityRegion).getByText((_content, element) => element?.tagName === 'STRONG' && element.textContent === 'Reply from u/alex')).toBeInTheDocument();
expect(within(activityRegion).getByRole('link', { name: 'u/alex' })).toHaveAttribute('href', 'https://www.reddit.com/u/alex');
const jobsRegion = screen.getByRole('region', { name: 'Sync jobs' });
expect(await within(jobsRegion).findByText('4 of 10')).toBeInTheDocument();
for (const name of ['Sync latest chats', 'Full cache sync', 'Fetch all chats']) {
expect(within(jobsRegion).getByRole('button', { name })).toHaveClass('button', 'secondary');
}
expect(screen.getByRole('status', { name: 'Realtime connection' })).toHaveTextContent('State may be stale');
const consoleRegion = screen.getByRole('region', { name: 'Live console' });
expect(consoleRegion).toHaveTextContent('Sync complete');
expect(consoleRegion).toHaveTextContent('Matrix request failed: M_LIMIT_EXCEEDED');
expect(within(consoleRegion).getByRole('link', { name: 'u/operator' })).toHaveAttribute(
'href',
'https://www.reddit.com/u/operator',
);
expect(within(consoleRegion).getByText('2 retained')).toBeInTheDocument();
await user.selectOptions(within(consoleRegion).getByLabelText('Log level'), 'error');
expect(consoleRegion).not.toHaveTextContent('Sync complete');
expect(consoleRegion).toHaveTextContent('Matrix request failed: M_LIMIT_EXCEEDED');
expect(within(consoleRegion).getByText('1 shown · 2 retained')).toBeInTheDocument();
await user.type(within(consoleRegion).getByLabelText('Search logs'), 'no match');
expect(consoleRegion).toHaveTextContent('No logs match the current filters.');
expect(within(consoleRegion).getByText('0 shown · 2 retained')).toBeInTheDocument();
expect(within(consoleRegion).getByRole('button', { name: 'Pause console' })).toHaveAttribute('aria-pressed', 'false');
expect(within(consoleRegion).getByLabelText('Auto-scroll')).toBeChecked();
await user.clear(within(consoleRegion).getByLabelText('Search logs'));
await user.click(within(consoleRegion).getByRole('button', { name: 'Clear local console' }));
expect(consoleRegion).toHaveTextContent('Server logs were not deleted');
});
it('starts delta sync directly and confirms expensive chat cache operations', async () => {
let deltaCalls = 0;
let fullCalls = 0;
server.use(
http.get('/api/activity', () => HttpResponse.json([])),
http.get('/api/logs', () => HttpResponse.json([])),
http.get('/api/chats/sync', () => HttpResponse.json({ syncing: false, syncingKind: null, lastFullSyncAt: null, lastDeltaSyncAt: null, hasEverFullSynced: false, cachedChats: 2, progress: null })),
http.post('/api/chats/sync/delta', () => { deltaCalls += 1; return HttpResponse.json({ success: true, kind: 'delta' }); }),
http.post('/api/chats/sync/full', () => { fullCalls += 1; return HttpResponse.json({ success: true, kind: 'full' }); }),
http.post('/api/chats/fetch-all', () => HttpResponse.json({ success: true, count: 7 })),
);
const user = userEvent.setup();
renderPage(<OperationsPage realtimeEnabled={false} />);
await user.click(await screen.findByRole('button', { name: 'Sync latest chats' }));
expect(await screen.findByText(/Delta sync started/)).toBeInTheDocument();
expect(deltaCalls).toBe(1);
// Expensive operations require an in-app confirmation dialog with action-specific copy.
await user.click(screen.getByRole('button', { name: 'Full cache sync' }));
const fullDialog = screen.getByRole('dialog', { name: 'Run a full cache sync?' });
expect(fullDialog).toHaveTextContent('can take significantly longer than a delta sync');
await user.click(within(fullDialog).getByRole('button', { name: 'Cancel' }));
expect(fullCalls).toBe(0);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Full cache sync' }));
await user.click(within(screen.getByRole('dialog', { name: 'Run a full cache sync?' })).getByRole('button', { name: 'Run full sync' }));
expect(await screen.findByText(/Full sync started/)).toBeInTheDocument();
expect(fullCalls).toBe(1);
await user.click(screen.getByRole('button', { name: 'Fetch all chats' }));
const fetchAllDialog = screen.getByRole('dialog', { name: 'Fetch every chat now?' });
expect(fetchAllDialog).toHaveTextContent('legacy blocking operation');
await user.click(within(fetchAllDialog).getByRole('button', { name: 'Start fetching' }));
expect(await screen.findByText('Fetched 7 chats.')).toBeInTheDocument();
});
});