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:
1 parent
82be197b79
commit
e984bf20ac
46 files changed
+2462
-495
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user