Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions app/src/__tests__/e2e/logs/logs-refresh-controls.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { test, expect, type Page } from '@playwright/test';
import { registerMocks } from '../../mocks/registerMocks';

// Refresh controls on the Query Logs page: the icon button is a one-shot refresh and
// the labeled "Auto" toggle owns the 10s loop. Interval/pill mechanics live in unit
// tests (QueryLogs.test.tsx); here we pin the wire-level behavior and accessibility.

const profile = { id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } };

const logItem = (i: number, domain: string) => ({
profile_id: 'prof1',
timestamp: `2026-08-12T10:00:${(59 - i).toString().padStart(2, '0')}Z`,
status: 'processed',
protocol: 'dns',
device_id: `device-${i}`,
client_ip: `10.0.0.${i}`,
dns_request: { domain, query_type: 'A' },
});

// Register the logs route AFTER registerMocks so it is tested BEFORE the catch-all
// route (Playwright matches routes in reverse registration order). The catch-all in
// registerMocks matches `/api/v1/profiles` and would otherwise shadow this endpoint.
// `respond` is re-evaluated per request (never keyed on call count — StrictMode
// double-fires the mount fetch in dev, so call indices are not deterministic).
async function setupLogsPage(page: Page, respond: () => object[]) {
await registerMocks(page, { authenticated: true, customProfiles: [profile] });
let calls = 0;
await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => {
calls++;
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(respond()) });
});
await page.goto('/query-logs');
await page.getByTestId('logs-scroll-container').waitFor({ state: 'attached', timeout: 10000 });
return { logsCalls: () => calls };
}

// Two instances render (mobile row / desktop row); only one is visible per breakpoint.
const visibleControl = (page: Page, testId: string) =>
page.locator(`[data-testid="${testId}"]:visible`);

test.describe('Logs refresh controls', () => {
test('split button halves stay equal height at tablet width', async ({ page }) => {
// The Button default size carries sm:h-9, which silently shrinks the interval
// trigger below the icon half on 640-1024px viewports unless pinned.
await page.setViewportSize({ width: 834, height: 1112 });
await setupLogsPage(page, () => [logItem(1, 'one.example.test')]);
const refresh = await visibleControl(page, 'logs-refresh-button').boundingBox();
const trigger = await visibleControl(page, 'logs-refresh-interval-trigger').boundingBox();
expect(refresh && trigger && refresh.height === trigger.height && refresh.y === trigger.y).toBe(true);
});

test('refresh button is a one-shot refresh with an accessible name', async ({ page }) => {
const { logsCalls } = await setupLogsPage(page, () => [logItem(1, 'one.example.test')]);
const initialCalls = logsCalls();

const refresh = visibleControl(page, 'logs-refresh-button');
await expect(refresh).toHaveAccessibleName('Refresh query logs');

await refresh.click();
await expect.poll(logsCalls).toBe(initialCalls + 1);

// The click is acknowledged with at least one full rotation even when the
// response lands instantly...
await expect(refresh.locator('svg')).toHaveClass(/animate-spin/);
// ...then the spin stops: one-shot means no lingering animation and no
// auto-refresh mode (no interval label on the split button).
await expect(refresh.locator('svg')).not.toHaveClass(/animate-spin|animate-\[/, { timeout: 3000 });
await expect(page.getByTestId('logs-refresh-interval-label')).toHaveCount(0);
expect(logsCalls()).toBe(initialCalls + 1);
});

test('interval menu enables live mode and stages new entries behind the pill', async ({ page }) => {
const initial = [logItem(1, 'one.example.test'), logItem(2, 'two.example.test')];
const fresh = logItem(0, 'fresh.example.test');
let dataset = initial;
await setupLogsPage(page, () => dataset);

await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(2);
// From here on, one new entry sits above the known head — the immediate tick
// fired by enabling auto-refresh should stage it, not apply it.
dataset = [fresh, ...initial];

const intervalTrigger = visibleControl(page, 'logs-refresh-interval-trigger');
await expect(intervalTrigger).toHaveAccessibleName('Auto-refresh interval');

// The menu offers the full interval set.
await intervalTrigger.click();
for (const key of ['off', 'auto', '5s', '10s', '15s', '30s', '60s']) {
await expect(page.getByTestId(`logs-refresh-interval-${key}`)).toBeVisible();
}
// Desktop: the menu opens down-right (extends past the trigger's right edge,
// into the page margin) so it doesn't drop over the quick-rule column at the
// right edge of the cards below. Radix may clamp the exact left edge to keep
// the menu inside the viewport, so assert the direction, not exact alignment.
if (test.info().project.name === 'chromium-desktop') {
const menuBox = await page.getByTestId('logs-refresh-interval-auto').boundingBox();
const triggerBox = await intervalTrigger.boundingBox();
expect(menuBox && triggerBox && menuBox.x + menuBox.width > triggerBox.x + triggerBox.width + 10).toBe(true);
}
await page.getByTestId('logs-refresh-interval-auto').click();

// Live cues: compact label on the split button + continuously spinning icon.
await expect(visibleControl(page, 'logs-refresh-interval-label')).toHaveText('Auto');
await expect(visibleControl(page, 'logs-refresh-button').locator('svg')).toHaveClass(/animate-\[spin_3s_linear_infinite\]/);

// The immediate tick stages the new entry — the list itself must not change.
const pill = page.getByTestId('logs-new-queries-pill');
await expect(pill).toBeVisible();
await expect(pill).toHaveText(/1 new query/);
await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(2);
// Clickability affordance: pointer cursor on hover.
expect(await pill.evaluate(el => getComputedStyle(el).cursor)).toBe('pointer');

// Revealing prepends without a reload.
await pill.click();
await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(3);
await expect(pill).toHaveCount(0);

// Off stops live mode: label disappears, icon stops spinning.
await intervalTrigger.click();
await page.getByTestId('logs-refresh-interval-off').click();
await expect(page.getByTestId('logs-refresh-interval-label')).toHaveCount(0);
await expect(visibleControl(page, 'logs-refresh-button').locator('svg')).not.toHaveClass(/animate-spin|animate-\[/);
});
});
42 changes: 42 additions & 0 deletions app/src/__tests__/unit/QueryLogCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,48 @@ describe('QueryLogCard whole-card expansion', () => {
expect(screen.getByTestId('querylog-detail-domain')).toHaveTextContent('Domain logging disabled');
});

test('controlled mode renders the expanded prop and reports toggles without flipping itself', () => {
const onToggleExpanded = vi.fn();
const { rerender } = render(
<QueryLogCard log={baseLog} expanded={false} onToggleExpanded={onToggleExpanded} />
);
const toggle = screen.getByTestId('querylog-card-toggle');
fireEvent.click(toggle);
expect(onToggleExpanded).toHaveBeenCalledTimes(1);
// State is owned by the parent — the card must not expand on its own.
expect(screen.getByTestId('querylog-expanded-panel')).toHaveAttribute('data-expanded', 'false');

rerender(<QueryLogCard log={baseLog} expanded={true} onToggleExpanded={onToggleExpanded} />);
expect(screen.getByTestId('querylog-expanded-panel')).toHaveAttribute('data-expanded', 'true');
expect(toggle).toHaveAttribute('aria-expanded', 'true');
});

test('controlled mode fires onExpand only when opening', () => {
const onExpand = vi.fn();
const { rerender } = render(
<QueryLogCard log={baseLog} expanded={false} onToggleExpanded={() => {}} onExpand={onExpand} />
);
fireEvent.click(screen.getByTestId('querylog-card-toggle'));
expect(onExpand).toHaveBeenCalledTimes(1);

rerender(
<QueryLogCard log={baseLog} expanded={true} onToggleExpanded={() => {}} onExpand={onExpand} />
);
// Collapsing an open card is not an "expand".
fireEvent.click(screen.getByTestId('querylog-card-toggle'));
expect(onExpand).toHaveBeenCalledTimes(1);
});

test('animateEntry plays the entry animation with a reduced-motion escape', () => {
const { container, rerender } = render(<QueryLogCard log={baseLog} animateEntry />);
const root = container.firstElementChild as HTMLElement;
expect(root.className).toContain('animate-in');
expect(root.className).toContain('motion-reduce:animate-none');

rerender(<QueryLogCard log={baseLog} />);
expect((container.firstElementChild as HTMLElement).className).not.toContain('animate-in');
});

test('there is no visible chevron indicator', () => {
render(<QueryLogCard log={baseLog} />);
expect(screen.queryByTestId('querylog-expand-indicator')).not.toBeInTheDocument();
Expand Down
Loading
Loading