// @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( , ); 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'); }); });