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>
This commit is contained in:
Jamie BruntonandClaude Fable 5 committed 2026-08-10 09:07:42 +01:00
1 parent 82be197b79
commit e984bf20ac
46 files changed
+2462 -495

No files matched your search

+57 -2
View File
@@ -2,7 +2,7 @@
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
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';
@@ -339,7 +339,7 @@ describe('dashboard foundation', () => {
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(
expect(within(dialog).getByRole('link', { name: /Open Reddit post/ })).toHaveAttribute(
'href', 'https://www.reddit.com/r/example/comments/source/the_source_post_title/',
);
});
@@ -401,4 +401,59 @@ describe('dashboard foundation', () => {
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();
}
});
});
+108
View File
@@ -0,0 +1,108 @@
// @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 { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { MemoryRouter } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { AttentionPage } from '../../apps/dashboard/src/features/attention/attention-page.js';
const MINUTE_MS = 60_000;
function attentionResponse(items: unknown[]) {
return { generatedAt: Date.now() - MINUTE_MS, items };
}
const criticalItem = {
id: 'item-critical',
kind: 'human-required',
severity: 'critical',
title: 'Human handoff required',
detail: 'Automation stopped and needs an operator reply.',
occurredAt: Date.now() - (14 * MINUTE_MS),
conversationId: 'room-alice',
username: 'alice',
};
const warningItem = {
id: 'item-warning',
kind: 'invitation-cooldown',
severity: 'warning',
title: 'Invitation cooldown active',
detail: 'Reddit is rate limiting new chat invitations.',
occurredAt: Date.now() - (2 * 60 * MINUTE_MS),
};
const server = setupServer();
function renderAttention() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<AttentionPage />
</QueryClientProvider>
</MemoryRouter>,
);
return queryClient;
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
describe('AttentionPage', () => {
it('labels critical items and the critical count with the danger pill', async () => {
server.use(http.get('/api/attention', () => HttpResponse.json(attentionResponse([criticalItem, warningItem]))));
renderAttention();
const items = await screen.findByRole('list', { name: 'Attention items' });
const [first, second] = within(items).getAllByRole('listitem');
expect(within(first).getByText('human required')).toHaveClass('pill', 'danger');
expect(within(second).getByText('invitation cooldown')).toHaveClass('pill', 'warning');
expect(screen.getByText('1 critical')).toHaveClass('pill', 'danger');
});
it('shows relative times with exact timestamps as titles', async () => {
server.use(http.get('/api/attention', () => HttpResponse.json(attentionResponse([criticalItem]))));
renderAttention();
const time = await screen.findByText('14m ago');
expect(time.tagName).toBe('TIME');
expect(time).toHaveAttribute('dateTime', new Date(criticalItem.occurredAt).toISOString());
expect(time).toHaveAttribute('title');
expect(screen.getByText(/Auto-refreshes every 30s/)).toBeInTheDocument();
});
it('links conversation items and gives unlinked items a place to act', async () => {
server.use(http.get('/api/attention', () => HttpResponse.json(attentionResponse([criticalItem, warningItem]))));
renderAttention();
expect(await screen.findByRole('link', { name: 'Open conversation' })).toHaveAttribute(
'href',
'/conversations/room-alice',
);
expect(screen.getByText('No linked conversation — check Operations.')).toBeInTheDocument();
expect(screen.getByText('Reddit is rate limiting new chat invitations.')).toBeInTheDocument();
});
it('uses the dedicated attention dot for the open-items summary', async () => {
server.use(http.get('/api/attention', () => HttpResponse.json(attentionResponse([criticalItem]))));
renderAttention();
await screen.findByText('Open items');
const strip = screen.getByRole('region', { name: 'Attention totals' });
const dot = strip.querySelector('.attention-status-dot');
expect(dot).toHaveClass('is-open');
expect(dot).not.toHaveClass('running');
expect(dot).not.toHaveClass('stopped');
});
});
+131 -6
View File
@@ -2,12 +2,14 @@
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import { cleanup, fireEvent, 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 { MemoryRouter } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { ConversationsPage } from '../../apps/dashboard/src/features/conversations/conversations-page.js';
import { resetComposerDrafts } from '../../apps/dashboard/src/features/conversations/composer-drafts.js';
interface ChatFixtureOptions {
username: string;
@@ -81,9 +83,11 @@ function renderConversations() {
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
<ConversationsPage />
</QueryClientProvider>,
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<ConversationsPage />
</QueryClientProvider>
</MemoryRouter>,
);
return queryClient;
}
@@ -92,6 +96,7 @@ beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
resetComposerDrafts();
});
afterAll(() => server.close());
@@ -152,8 +157,7 @@ describe('ConversationsPage', () => {
);
expect(screen.getByText('2h ago')).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveClass('is-blurred');
expect(screen.getByRole('button', { name: 'View source image full screen: Looking for a chat' })).toBeDisabled();
await user.click(screen.getByRole('button', { name: 'Reveal original image' }));
await user.click(screen.getByRole('button', { name: 'Reveal source image: Looking for a chat' }));
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).not.toHaveClass('is-blurred');
const previewTrigger = screen.getByRole('button', { name: 'View source image full screen: Looking for a chat' });
await user.click(previewTrigger);
@@ -321,6 +325,7 @@ describe('ConversationsPage', () => {
);
const user = userEvent.setup();
renderConversations();
await user.click(screen.getByText('Processed user lookup'));
await user.type(screen.getByLabelText('Lookup Reddit username'), 'u/alice');
await user.click(screen.getByRole('button', { name: 'Look up user' }));
expect(await screen.findByRole('list', { name: 'Processed posts for u/alice' })).toHaveTextContent('A processed post');
@@ -438,4 +443,124 @@ describe('ConversationsPage', () => {
expect(message).toHaveValue('');
expect(startRequests).toBe(2);
});
it('explains how to enable the start-conversation form when no accounts are running', async () => {
server.use(
http.get('/api/conversations', () => HttpResponse.json([])),
http.get('/api/accounts', () => HttpResponse.json([])),
);
const user = userEvent.setup();
renderConversations();
await user.click(screen.getByRole('button', { name: 'New conversation' }));
expect(await screen.findByText(/No running accounts/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute('href', '/settings');
expect(screen.queryByRole('button', { name: 'Start conversation' })).not.toBeInTheDocument();
});
it('shows the unread count and marks unread rows with state and time in the label', async () => {
installConversationList();
renderConversations();
const aliceButton = await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('1 unread')).toBeInTheDocument();
expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Unread, .+ ago/);
expect(aliceButton.closest('li')).toHaveClass('is-unread');
expect(screen.getByRole('button', { name: /u\/bob/ }).closest('li')).not.toHaveClass('is-unread');
});
it('offers a jump-to-latest control when the operator scrolls up in the history', async () => {
installConversationList();
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
const history = await screen.findByRole('list', { name: 'Message history' });
expect(screen.queryByRole('button', { name: 'Jump to latest' })).not.toBeInTheDocument();
Object.defineProperty(history, 'scrollHeight', { configurable: true, value: 1000 });
Object.defineProperty(history, 'clientHeight', { configurable: true, value: 300 });
history.scrollTop = 100;
fireEvent.scroll(history);
const jumpButton = await screen.findByRole('button', { name: 'Jump to latest' });
await user.click(jumpButton);
expect(history.scrollTop).toBe(1000);
expect(screen.queryByRole('button', { name: 'Jump to latest' })).not.toBeInTheDocument();
});
it('preserves an unsent draft when switching away from and back to a conversation', async () => {
installConversationList();
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' });
await user.type(composer, 'Draft in progress');
await user.click(screen.getByRole('button', { name: 'Close conversation' }));
expect(screen.queryByRole('textbox', { name: 'Reply to u/alice' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /u\/alice/ }));
expect(await screen.findByRole('textbox', { name: 'Reply to u/alice' })).toHaveValue('Draft in progress');
});
it('lets the operator undo a suggestion that replaced a typed draft', async () => {
installConversationList();
server.use(
http.post('/api/conversations/room-alice/suggest-reply', () =>
HttpResponse.json({ suggestedReply: 'A generated suggestion' }),
),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' });
await user.type(composer, 'My own words');
expect(screen.getByText('12 / 10000')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Suggest reply' }));
await waitFor(() => expect(composer).toHaveValue('A generated suggestion'));
expect(screen.getByText('The suggestion replaced your draft.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Undo' }));
expect(composer).toHaveValue('My own words');
expect(screen.queryByRole('button', { name: 'Undo' })).not.toBeInTheDocument();
});
it('keeps the confirmation image blurred when saved images were re-blurred before review', async () => {
const asset = {
id: '22222222-2222-4222-8222-222222222222', tags: ['selfie'], safety: 'sfw',
classificationStatus: 'ready', description: 'A selfie', confidence: 0.95,
fileName: 'selfie.jpg', mimeType: 'image/jpeg', sizeBytes: 1024, width: 800, height: 1000,
enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
sendCount: 0, lastSentAt: null, urlPath: '/library-media/selfie.jpg',
};
server.use(
http.get('/api/conversations', () => HttpResponse.json([alice])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture())),
http.get('/api/conversations/room-alice/shareable-media', () => HttpResponse.json({
requestKind: 'selfie', assets: [asset], reason: null,
})),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await user.click(await screen.findByText('Share a saved image'));
expect(await screen.findByText('Reveal images to select one.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Reveal saved images' }));
expect(screen.queryByText('Reveal images to select one.')).not.toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: /Saved image tagged selfie/ }));
await user.click(screen.getByRole('button', { name: 'Blur saved images' }));
await user.click(screen.getByRole('button', { name: 'Review image share' }));
const dialog = screen.getByRole('dialog', { name: 'Share this saved image?' });
const dialogImage = within(dialog).getByRole('img', { name: 'Saved image tagged selfie' });
expect(dialogImage).toHaveClass('is-blurred');
await user.click(within(dialog).getByRole('button', { name: 'Reveal image' }));
expect(dialogImage).not.toHaveClass('is-blurred');
});
});
+149 -3
View File
@@ -2,7 +2,7 @@
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { 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';
@@ -17,6 +17,12 @@ const asset = {
sendCount: 0, lastSentAt: null,
urlPath: '/library-media/portrait.jpg',
};
const secondAsset = {
...asset,
id: '029fe789-1b99-7ce1-8402-b3cbc32a445b',
fileName: 'second.jpg',
urlPath: '/library-media/second.jpg',
};
const server = setupServer();
function renderPage() {
@@ -24,6 +30,13 @@ function renderPage() {
return render(<QueryClientProvider client={client}><MediaLibraryPage /></QueryClientProvider>);
}
function cardFor(fileName: string): HTMLElement {
const title = screen.getByText(fileName, { selector: 'strong' });
const card = title.closest('article');
if (!card) throw new Error(`No card for ${fileName}`);
return card;
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => { cleanup(); server.resetHandlers(); vi.restoreAllMocks(); });
afterAll(() => server.close());
@@ -43,14 +56,101 @@ describe('exchange library page', () => {
const image = await screen.findByRole('img', { name: /Library image tagged selfie/i });
expect(image).toHaveClass('is-blurred');
await user.click(screen.getByRole('button', { name: 'Reveal' }));
const revealToggle = screen.getByRole('button', { name: 'Reveal portrait.jpg' });
expect(revealToggle).toHaveAttribute('aria-pressed', 'false');
await user.click(revealToggle);
expect(image).not.toHaveClass('is-blurred');
expect(screen.getByRole('button', { name: 'Blur portrait.jpg' })).toHaveAttribute('aria-pressed', 'true');
await user.click(screen.getByRole('checkbox', { name: 'topless' }));
expect(screen.getByText('Unsaved changes')).toBeInTheDocument();
await user.selectOptions(screen.getByRole('combobox', { name: 'Safety' }), 'nsfw');
await user.click(screen.getByRole('button', { name: 'Save changes' }));
await waitFor(() => expect(update).toEqual({ tags: ['selfie', 'topless'], safety: 'nsfw', enabled: true }));
// Transient save feedback next to the Save button.
const saved = await screen.findByText('Saved');
expect(saved).toHaveAttribute('role', 'status');
await waitFor(() => expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument());
});
it('opens a revealed library image in the enlarging preview dialog', async () => {
server.use(http.get('/api/media-library', () => HttpResponse.json([asset])));
const user = userEvent.setup();
renderPage();
await screen.findByRole('img', { name: /Library image tagged selfie/i });
await user.click(screen.getByRole('button', { name: 'Reveal library image portrait.jpg' }));
await user.click(screen.getByRole('button', { name: 'View portrait.jpg larger' }));
const dialog = screen.getByRole('dialog', { name: 'Preview of portrait.jpg' });
expect(within(dialog).getByRole('img')).toHaveAttribute('src', '/library-media/portrait.jpg');
// Single-item mode: no prev/next controls.
expect(within(dialog).queryByRole('button', { name: 'Next media' })).not.toBeInTheDocument();
await user.click(within(dialog).getByRole('button', { name: 'Close media preview' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('preserves a touched form when a refetch replaces the assets', async () => {
let update: unknown;
server.use(
http.get('/api/media-library', () => HttpResponse.json([asset, secondAsset])),
http.patch('/api/media-library/:id', async ({ request }) => {
update = await request.json();
return HttpResponse.json({ ...asset, ...(update as object) });
}),
);
const user = userEvent.setup();
renderPage();
await screen.findByText('portrait.jpg');
const secondCard = cardFor('second.jpg');
// Touch the second card's form without saving it.
await user.click(within(secondCard).getByRole('checkbox', { name: 'topless' }));
expect(within(secondCard).getByText('Unsaved changes')).toBeInTheDocument();
// Saving the first card invalidates and refetches the whole list.
const firstCard = cardFor('portrait.jpg');
await user.click(within(firstCard).getByRole('checkbox', { name: 'chest' }));
await user.click(within(firstCard).getByRole('button', { name: 'Save changes' }));
await waitFor(() => expect(update).toEqual({ tags: ['selfie', 'chest'], safety: 'sfw', enabled: true }));
await within(firstCard).findByText('Saved');
// The refetch must not clobber the second card's unsaved edit.
expect(within(secondCard).getByRole('checkbox', { name: 'topless' })).toBeChecked();
expect(within(secondCard).getByText('Unsaved changes')).toBeInTheDocument();
});
it('explains why saving is disabled when no tags are selected', async () => {
server.use(http.get('/api/media-library', () => HttpResponse.json([asset])));
const user = userEvent.setup();
renderPage();
await screen.findByText('portrait.jpg');
await user.click(screen.getByRole('checkbox', { name: 'selfie' }));
expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled();
expect(screen.getByText('Select at least one tag to save')).toBeInTheDocument();
});
it('arms deletion away from the Delete position and disarms it after five seconds', async () => {
server.use(http.get('/api/media-library', () => HttpResponse.json([asset])));
const user = userEvent.setup();
renderPage();
await screen.findByText('portrait.jpg');
await user.click(screen.getByRole('button', { name: 'Delete' }));
const actions = screen.getByRole('button', { name: 'Confirm delete' }).parentElement!;
const buttons = within(actions).getAllByRole('button');
// Cancel occupies the slot Delete was in; Confirm sits after it.
expect(buttons.map((button) => button.textContent)).toEqual(['Save changes', 'Cancel', 'Confirm delete']);
await waitFor(
() => expect(screen.queryByRole('button', { name: 'Confirm delete' })).not.toBeInTheDocument(),
{ timeout: 7_000 },
);
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
}, 15_000);
it('requires consent in the UI and uploads the selected image for classification', async () => {
let consentHeader: string | null = null;
server.use(
@@ -65,12 +165,58 @@ describe('exchange library page', () => {
await screen.findByText('No exchange images yet.');
const upload = screen.getByRole('button', { name: 'Upload and classify' });
expect(upload).toBeDisabled();
await user.upload(screen.getByLabelText(/Choose image/), new File(['image'], 'portrait.jpg', { type: 'image/jpeg' }));
const input = screen.getByLabelText(/Choose image/) as HTMLInputElement;
await user.upload(input, new File(['image'], 'portrait.jpg', { type: 'image/jpeg' }));
expect(upload).toBeDisabled();
await user.click(screen.getByRole('checkbox', { name: /I confirm every depicted adult consented/i }));
expect(upload).toBeEnabled();
await user.click(upload);
await screen.findByText('Image added. Review its tags before relying on it.');
expect(consentHeader).toBe('true');
// The input resets so the same file can be picked again.
expect(input.value).toBe('');
expect(screen.getByText('No image selected')).toBeInTheDocument();
});
it('rejects non-image and oversized files before uploading', async () => {
server.use(http.get('/api/media-library', () => HttpResponse.json([])));
const user = userEvent.setup({ applyAccept: false });
renderPage();
await screen.findByText('No exchange images yet.');
const input = screen.getByLabelText(/Choose image/) as HTMLInputElement;
await user.upload(input, new File(['nope'], 'notes.txt', { type: 'text/plain' }));
expect(screen.getByRole('alert')).toHaveTextContent('Only image files can be uploaded.');
expect(screen.getByRole('button', { name: 'Upload and classify' })).toBeDisabled();
const oversized = new File(['big'], 'huge.jpg', { type: 'image/jpeg' });
Object.defineProperty(oversized, 'size', { value: 16 * 1024 * 1024 });
await user.upload(input, oversized);
expect(screen.getByRole('alert')).toHaveTextContent('Images must be 15 MB or smaller.');
expect(screen.getByText('No image selected')).toBeInTheDocument();
});
it('shows a thumbnail preview of the picked image when object URLs are available', async () => {
const objectUrls: string[] = [];
const globalUrl = URL as unknown as Record<string, unknown>;
globalUrl.createObjectURL = vi.fn(() => {
const url = `blob:preview-${objectUrls.length}`;
objectUrls.push(url);
return url;
});
globalUrl.revokeObjectURL = vi.fn();
try {
server.use(http.get('/api/media-library', () => HttpResponse.json([])));
const user = userEvent.setup();
renderPage();
await screen.findByText('No exchange images yet.');
await user.upload(screen.getByLabelText(/Choose image/), new File(['image'], 'portrait.jpg', { type: 'image/jpeg' }));
expect(screen.getByRole('img', { name: 'Preview of portrait.jpg' })).toHaveAttribute('src', 'blob:preview-0');
} finally {
delete globalUrl.createObjectURL;
delete globalUrl.revokeObjectURL;
}
});
});
+138 -11
View File
@@ -30,6 +30,15 @@ const mediaItems = [
},
];
function altName(kind: 'Image' | 'GIF', sender: string, timestamp: number): string {
const date = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).format(timestamp);
return `${kind} from u/${sender}, ${date}`;
}
const aliceAlt = altName('Image', 'alice', mediaItems[0].timestamp);
const charlieAlt = altName('GIF', 'charlie', mediaItems[1].timestamp);
const daveAlt = altName('Image', 'dave', mediaItems[2].timestamp);
const idleStatus = {
isSyncing: false,
lastSyncAt: now - 120_000,
@@ -62,6 +71,11 @@ function renderMedia() {
return { ...view, queryClient };
}
/** The card's footer toggle carries aria-pressed; the image button does not. */
function revealToggle(sender: string, pressed: boolean) {
return screen.getByRole('button', { name: `Reveal media from ${sender}`, pressed });
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
@@ -91,8 +105,8 @@ describe('media page', () => {
await waitFor(() => expect(resolveMedia).toBeTypeOf('function'));
resolveMedia?.(HttpResponse.json(mediaItems));
const aliceImage = await screen.findByRole('img', { name: 'Media from alice' });
const charlieImage = screen.getByRole('img', { name: 'Media from charlie' });
const aliceImage = await screen.findByRole('img', { name: aliceAlt });
const charlieImage = screen.getByRole('img', { name: charlieAlt });
for (const profileLink of screen.getAllByRole('link', { name: 'alice' })) {
expect(profileLink).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
}
@@ -100,17 +114,44 @@ describe('media page', () => {
expect(charlieImage).toHaveClass('is-blurred');
expect(credentials.every((value) => value === 'same-origin')).toBe(true);
await user.click(screen.getByRole('button', { name: 'Reveal media from alice' }));
await user.click(revealToggle('alice', false));
expect(aliceImage).not.toHaveClass('is-blurred');
expect(charlieImage).toHaveClass('is-blurred');
await user.click(screen.getByRole('button', { name: 'Reveal all media' }));
expect(charlieImage).not.toHaveClass('is-blurred');
// Turning reveal-all back off keeps the earlier individual reveal intact.
await user.click(screen.getByRole('button', { name: 'Blur all media' }));
expect(charlieImage).toHaveClass('is-blurred');
expect(aliceImage).not.toHaveClass('is-blurred');
firstView.unmount();
installMediaApi();
renderMedia();
expect(await screen.findByRole('img', { name: 'Media from alice' })).toHaveClass('is-blurred');
expect(await screen.findByRole('img', { name: aliceAlt })).toHaveClass('is-blurred');
});
it('reveals blurred media when its preview surface is clicked', async () => {
installMediaApi();
const user = userEvent.setup();
renderMedia();
const aliceImage = await screen.findByRole('img', { name: aliceAlt });
expect(aliceImage).toHaveClass('is-blurred');
// The image surface itself acts as a reveal control while blurred.
const imageButton = screen
.getAllByRole('button', { name: 'Reveal media from alice' })
.find((button) => button.classList.contains('media-preview-button'));
expect(imageButton).toBeDefined();
expect(imageButton).toBeEnabled();
await user.click(imageButton!);
expect(aliceImage).not.toHaveClass('is-blurred');
// Once revealed, the same surface opens the full-size preview instead.
await user.click(screen.getByRole('button', { name: 'View media from alice larger' }));
expect(screen.getByRole('dialog', { name: 'Media from alice' })).toBeInTheDocument();
});
it('opens revealed media in a dismissible full-size preview', async () => {
@@ -118,16 +159,43 @@ describe('media page', () => {
const user = userEvent.setup();
renderMedia();
await user.click(await screen.findByRole('button', { name: 'Reveal media from alice' }));
await screen.findByRole('img', { name: aliceAlt });
await user.click(revealToggle('alice', false));
await user.click(screen.getByRole('button', { name: 'View media from alice larger' }));
const dialog = screen.getByRole('dialog', { name: 'Media from alice' });
expect(within(dialog).getByRole('img', { name: 'Media from alice' })).toHaveAttribute('src', '/media/one.jpg');
expect(within(dialog).getByRole('img', { name: aliceAlt })).toHaveAttribute('src', '/media/one.jpg');
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog', { name: 'Media from alice' })).not.toBeInTheDocument();
});
it('navigates the preview across the visible items with buttons and arrow keys', async () => {
installMediaApi();
const user = userEvent.setup();
renderMedia();
await screen.findByRole('img', { name: aliceAlt });
await user.click(screen.getByRole('button', { name: 'Reveal all media' }));
await user.click(screen.getByRole('button', { name: 'View media from alice larger' }));
let dialog = screen.getByRole('dialog', { name: 'Media from alice' });
expect(within(dialog).getByRole('button', { name: 'Previous media' })).toBeDisabled();
expect(within(dialog).getByText('1 of 3')).toBeInTheDocument();
await user.click(within(dialog).getByRole('button', { name: 'Next media' }));
dialog = screen.getByRole('dialog', { name: 'Media from charlie' });
expect(within(dialog).getByRole('img', { name: charlieAlt })).toHaveAttribute('src', '/media/two.gif');
expect(within(dialog).getByText('u/charlie')).toBeInTheDocument();
await user.keyboard('{ArrowRight}');
dialog = screen.getByRole('dialog', { name: 'Media from dave' });
expect(within(dialog).getByRole('button', { name: 'Next media' })).toBeDisabled();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('dialog', { name: 'Media from charlie' })).toBeInTheDocument();
});
it('links media cards to their account-owned conversations', async () => {
installMediaApi();
renderMedia();
@@ -149,11 +217,11 @@ describe('media page', () => {
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'alice');
expect(screen.getAllByRole('img')).toHaveLength(1);
expect(screen.getByRole('img', { name: 'Media from alice' })).toBeInTheDocument();
expect(screen.getByRole('img', { name: aliceAlt })).toBeInTheDocument();
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'all');
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'charlie');
expect(screen.getByRole('img', { name: 'Media from charlie' })).toBeInTheDocument();
expect(screen.getByRole('img', { name: charlieAlt })).toBeInTheDocument();
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'all');
await user.selectOptions(screen.getByRole('combobox', { name: 'Media type' }), 'gif');
@@ -167,6 +235,49 @@ describe('media page', () => {
expect(screen.getByText('No media matches these filters.')).toBeInTheDocument();
});
it('shows a result count and clears filters from both the panel and the empty state', async () => {
installMediaApi();
const user = userEvent.setup();
renderMedia();
await screen.findByRole('img', { name: aliceAlt });
expect(screen.getByText('Showing 3 of 3 items')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Clear filters' })).not.toBeInTheDocument();
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'alice');
expect(screen.getByText('Showing 1 of 3 items')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Clear filters' }));
expect(screen.getAllByRole('img')).toHaveLength(3);
expect(screen.getByRole('combobox', { name: 'Conversation' })).toHaveValue('all');
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'charlie');
await user.selectOptions(screen.getByRole('combobox', { name: 'Media type' }), 'image');
const emptyState = screen.getByText('No media matches these filters.').closest('section');
expect(emptyState).not.toBeNull();
await user.click(within(emptyState!).getByRole('button', { name: 'Clear filters' }));
expect(screen.getAllByRole('img')).toHaveLength(3);
});
it('paginates large galleries behind a Load more control', async () => {
const manyItems = Array.from({ length: 60 }, (_, index) => ({
id: `item-${index}`, chatUsername: 'alice', chatRoomId: `room-${index}`, sender: 'alice',
timestamp: now - index * 60_000, originalUrl: `https://media.example/${index}.jpg`,
fileName: `${index}.jpg`, relativePath: `${index}.jpg`, createdAt: now - index * 60_000,
urlPath: `/media/${index}.jpg`,
}));
installMediaApi(manyItems);
const user = userEvent.setup();
renderMedia();
await screen.findByText('Showing 48 of 60 items');
expect(screen.getAllByRole('img')).toHaveLength(48);
await user.click(screen.getByRole('button', { name: 'Load more (12 remaining)' }));
expect(screen.getAllByRole('img')).toHaveLength(60);
expect(screen.getByText('Showing 60 of 60 items')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Load more/ })).not.toBeInTheDocument();
});
it('orders media by received time', async () => {
server.use(
http.get('/api/media', () => HttpResponse.json([mediaItems[2], mediaItems[0], mediaItems[1]])),
@@ -175,9 +286,9 @@ describe('media page', () => {
renderMedia();
expect((await screen.findAllByRole('img')).map((item) => item.getAttribute('alt'))).toEqual([
'Media from alice',
'Media from charlie',
'Media from dave',
aliceAlt,
charlieAlt,
daveAlt,
]);
});
@@ -230,6 +341,22 @@ describe('media page', () => {
expect(await screen.findByRole('alert')).toHaveTextContent('Sync already in progress');
});
it('auto-dismisses the sync success notice after five seconds', async () => {
installMediaApi();
server.use(http.post('/api/media/sync', () => HttpResponse.json({ success: true })));
const user = userEvent.setup();
renderMedia();
await user.click(await screen.findByRole('button', { name: 'Sync new media' }));
const notice = await screen.findByText('Media sync completed.');
expect(notice).toHaveAttribute('role', 'status');
await waitFor(
() => expect(screen.queryByText('Media sync completed.')).not.toBeInTheDocument(),
{ timeout: 7_000 },
);
}, 15_000);
it('refreshes shared sync status as soon as a long-running mutation starts', async () => {
let statusRequests = 0;
let resolveSync: ((response: Response) => void) | undefined;
@@ -7,7 +7,7 @@ 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, vi } from 'vitest';
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';
@@ -97,27 +97,102 @@ describe('PerformancePage', () => {
renderPage(<PerformancePage />);
expect(await screen.findByRole('heading', { name: 'Performance' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Outcome funnel' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'AI cost and reliability' })).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 = screen.getByRole('table', { name: 'Subreddit performance' });
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 = screen.getByRole('table', { name: 'Strategy performance' });
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(screen.getByRole('list', { name: '30 day response trend' })).toHaveTextContent('50%');
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('A thoughtful opener');
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', () => {
@@ -181,11 +256,14 @@ describe('OperationsPage', () => {
'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'));
@@ -204,19 +282,29 @@ describe('OperationsPage', () => {
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 confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(false);
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);
confirmSpy.mockReturnValue(true);
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();
});
});
+154 -2
View File
@@ -2,7 +2,7 @@
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
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';
@@ -30,7 +30,10 @@ function renderSettings(path = '/settings') {
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const router = createMemoryRouter(
[{ path: '/settings', element: <SettingsRoute /> }],
[
{ path: '/settings', element: <SettingsRoute /> },
{ path: '/elsewhere', element: <h1>Elsewhere</h1> },
],
{ initialEntries: [path] },
);
const view = render(
@@ -112,9 +115,18 @@ describe('settings and access', () => {
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByText('Recent window must be greater than 0 minutes.')).toBeInTheDocument();
expect(recentWindow).toHaveAttribute('aria-invalid', 'true');
expect(screen.getByText('Resolve 1 validation error before saving.')).toBeInTheDocument();
await waitFor(() => expect(recentWindow).toHaveFocus());
expect(updateRequests).toBe(0);
// Discarding a dirty draft requires explicit confirmation.
await user.click(screen.getByRole('button', { name: 'Reset changes' }));
const resetDialog = screen.getByRole('dialog', { name: 'Discard unsaved settings?' });
await user.click(within(resetDialog).getByRole('button', { name: 'Stay' }));
expect(recentWindow).toHaveValue(0);
await user.click(screen.getByRole('button', { name: 'Reset changes' }));
await user.click(within(screen.getByRole('dialog', { name: 'Discard unsaved settings?' })).getByRole('button', { name: 'Discard changes' }));
expect(recentWindow).toHaveValue(60);
expect(screen.queryByText('You have unsaved changes.')).not.toBeInTheDocument();
});
@@ -340,6 +352,146 @@ describe('settings and access', () => {
expect(updateRequests).toBe(0);
});
it('confirms in-app navigation away from dirty settings and can discard the draft', async () => {
installConfigApi();
const user = userEvent.setup();
const { router } = renderSettings('/settings?section=monitoring');
const recentWindow = await screen.findByRole('spinbutton', { name: 'Recent window in minutes' });
await user.clear(recentWindow);
await user.type(recentWindow, '45');
await act(async () => { await router.navigate('/elsewhere'); });
const stayDialog = await screen.findByRole('dialog', { name: 'Discard unsaved settings?' });
await user.click(within(stayDialog).getByRole('button', { name: 'Stay' }));
expect(router.state.location.pathname).toBe('/settings');
expect(screen.getByRole('spinbutton', { name: 'Recent window in minutes' })).toHaveValue(45);
await act(async () => { await router.navigate('/elsewhere'); });
const discardDialog = await screen.findByRole('dialog', { name: 'Discard unsaved settings?' });
await user.click(within(discardDialog).getByRole('button', { name: 'Discard changes' }));
await waitFor(() => expect(router.state.location.pathname).toBe('/elsewhere'));
expect(screen.getByRole('heading', { name: 'Elsewhere' })).toBeInTheDocument();
});
it('requires confirmation for an autonomous mode on a renamed subreddit, matched by name not index', async () => {
const r4rConfig = {
...config,
subreddits: [{
name: 'casualconversation', type: 'chat', weight: 2, tone: 'default',
conversation: { mode: 'r4r', maxAutomatedTurns: 5, rapportTurnsBeforeMediaAsk: 3 },
}],
};
let updateRequests = 0;
server.use(
http.get('/api/config', () => HttpResponse.json(r4rConfig)),
http.put('/api/config', () => {
updateRequests += 1;
return HttpResponse.json({
success: true,
config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit },
subreddits: r4rConfig.subreddits,
});
}),
);
const user = userEvent.setup();
renderSettings('/settings?section=behaviour');
// The persisted row is already r4r at index 0; giving the row a new name is
// equivalent to inserting a new r4r-enabled row, so it must be re-confirmed.
const name = await screen.findByRole('textbox', { name: 'Subreddit 1 name' });
await user.clear(name);
await user.type(name, 'brandnewplace');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
const dialog = screen.getByRole('dialog', { name: 'Confirm risky settings' });
expect(dialog).toHaveTextContent('Autonomous R4R will send conversational replies without approval');
expect(updateRequests).toBe(0);
});
it('does not re-confirm an unchanged autonomous subreddit when a row before it is removed', async () => {
const twoSubredditConfig = {
...config,
subreddits: [
{ name: 'general', type: 'chat', weight: 1, tone: 'default', conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 } },
{ name: 'r4rlondon', type: 'chat', weight: 1, tone: 'default', conversation: { mode: 'r4r', maxAutomatedTurns: 5, rapportTurnsBeforeMediaAsk: 3 } },
],
};
let updateRequests = 0;
server.use(
http.get('/api/config', () => HttpResponse.json(twoSubredditConfig)),
http.put('/api/config', async ({ request }) => {
updateRequests += 1;
const body = await request.json() as { subreddits: typeof twoSubredditConfig.subreddits };
return HttpResponse.json({
success: true,
config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit },
subreddits: body.subreddits,
});
}),
);
const user = userEvent.setup();
renderSettings('/settings?section=behaviour');
// Removing the first row shifts the r4r row to index 0; by-name matching
// must recognise it as already approved and save without a risk dialog.
await user.click(await screen.findByRole('button', { name: 'Remove subreddit 1' }));
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.queryByRole('dialog', { name: 'Confirm risky settings' })).not.toBeInTheDocument();
expect(await screen.findByText('Settings saved.')).toBeInTheDocument();
expect(updateRequests).toBe(1);
});
it('notices a server-side config change while the draft is dirty and loads it on reset', async () => {
installConfigApi();
const user = userEvent.setup();
const { queryClient } = renderSettings('/settings?section=monitoring');
const recentWindow = await screen.findByRole('spinbutton', { name: 'Recent window in minutes' });
await user.clear(recentWindow);
await user.type(recentWindow, '45');
act(() => {
queryClient.setQueryData(['dashboard', 'config'], {
...config,
reddit: { ...config.reddit, maxReadyCandidates: 99 },
subreddits: [{
name: 'casualconversation', type: 'chat', weight: 2, tone: 'default',
conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 },
}],
});
});
expect(await screen.findByText('Settings changed on the server — Reset to load them.')).toBeInTheDocument();
expect(recentWindow).toHaveValue(45);
await user.click(screen.getByRole('button', { name: 'Reset changes' }));
await user.click(within(screen.getByRole('dialog', { name: 'Discard unsaved settings?' })).getByRole('button', { name: 'Discard changes' }));
expect(screen.getByRole('spinbutton', { name: 'Recent window in minutes' })).toHaveValue(60);
expect(screen.getByRole('spinbutton', { name: 'Maximum ready posts' })).toHaveValue(99);
});
it('rejects a non-JSON session dump inline without sending a request', async () => {
let importRequests = 0;
server.use(
http.get('/api/config', () => HttpResponse.json(config)),
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' })),
http.post('/api/login/session-import', () => {
importRequests += 1;
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
renderSettings('/settings?section=access');
const brokenDump = new File(['definitely not json'], 'broken.json', { type: 'application/json' });
await user.upload(await screen.findByLabelText('Reddit session export'), brokenDump);
expect(await screen.findByText(/isn't valid JSON/)).toBeInTheDocument();
expect(importRequests).toBe(0);
});
it('waits for and imports an extension dump without exposing credentials', async () => {
let statusRequests = 0;
let importRequests = 0;