Skip to content
Open
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
185 changes: 185 additions & 0 deletions app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,189 @@ test.describe('Logs mobile layout', () => {
});
expect(hasHorizontalScrollbar).toBeFalsy();
});

test('whole-card expansion: every row expands, quick-rule is excluded, no overflow with long labels', async ({ page }) => {
await registerMocks(page, {
authenticated: true,
customProfiles: [{ id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }]
});

// 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,
// returning the profiles array instead of our logs payload.
const now = new Date().toISOString();
const items = [
// Blocked row WITH reasons — deliberately long ids to challenge layout / overflow
{
profile_id: 'prof1',
timestamp: now,
status: 'blocked',
protocol: 'dns',
device_id: 'device-with-reasons',
client_ip: '10.0.0.1',
dns_request: { domain: 'blocked-with-reasons.example-longdomainforlayout-validation.test' },
reasons: [
'blocklist: very-long-blocklist-identifier-xxxxxxxxxxxxxxxxxxxx',
'service: another-long-service-id-yyyyyyyyyyyyyyyyyyyy'
]
},
// Processed row WITHOUT reasons — now also expandable (detail grid, no reasons block)
{
profile_id: 'prof1',
timestamp: now,
status: 'processed',
protocol: 'dns',
device_id: 'device-no-reasons',
client_ip: '10.0.0.2',
dns_request: { domain: 'processed-no-reasons.example.test' }
}
];
await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(items) });
});

await page.goto('/query-logs');

const scrollContainer = page.getByTestId('logs-scroll-container');
await scrollContainer.first().waitFor({ state: 'attached', timeout: 10000 });

// Every row is expandable now → one toggle + one panel per mocked row (2).
const toggles = page.getByTestId('querylog-card-toggle');
await expect(toggles).toHaveCount(items.length);
const panels = page.getByTestId('querylog-expanded-panel');
await expect(panels).toHaveCount(items.length);

const firstPanel = panels.nth(0);
const secondPanel = panels.nth(1);

// Collapsed initial state
await expect(toggles.nth(0)).toHaveAttribute('aria-expanded', 'false');
await expect(firstPanel).toHaveAttribute('data-expanded', 'false');

// Quick-rule button is excluded from the overlay: clicking it must NOT expand the card.
await page.getByTestId('logs-quick-rule-button').nth(0).click();
await expect(firstPanel).toHaveAttribute('data-expanded', 'false');
// Close any sheet the quick-rule action opened so it doesn't cover the cards below.
await page.keyboard.press('Escape');

// Keyboard: focus the first card's toggle and press Enter to expand.
await toggles.nth(0).focus();
await page.keyboard.press('Enter');
await expect(toggles.nth(0)).toHaveAttribute('aria-expanded', 'true');
await expect(firstPanel).toHaveAttribute('data-expanded', 'true');

// Expanded panel shows the detail grid (protocol + timestamp always render) and the reasons block.
await expect(firstPanel.getByTestId('querylog-detail-grid')).toBeVisible();
await expect(firstPanel.getByTestId('querylog-detail-protocol')).toBeVisible();
await expect(firstPanel.getByTestId('querylog-detail-timestamp')).toBeVisible();
await expect(firstPanel.getByTestId('querylog-reasons')).toBeVisible();

// The processed (no-reasons) row expands too: detail grid visible, but no reasons block.
await toggles.nth(1).click();
await expect(secondPanel).toHaveAttribute('data-expanded', 'true');
await expect(secondPanel.getByTestId('querylog-detail-grid')).toBeVisible();
await expect(secondPanel.getByTestId('querylog-reasons')).toHaveCount(0);

// Re-run overflow assertions AFTER expansion, with the long labels rendered
const result = await page.evaluate(() => {
const docEl = document.documentElement;
const body = document.body;
const vw = window.innerWidth;
const sc = document.querySelector('[data-testid="logs-scroll-container"]') as HTMLElement | null;
const scrollWidthDoc = Math.max(
body.scrollWidth,
docEl.scrollWidth,
body.offsetWidth,
docEl.offsetWidth
);
const scrollingElWidth = document.scrollingElement ? document.scrollingElement.scrollWidth : docEl.scrollWidth;
const scOverflow = sc ? sc.scrollWidth - sc.clientWidth : 0;
return { vw, scrollWidthDoc, scrollingElWidth, scOverflow };
});
expect(result.scrollingElWidth).toBeLessThanOrEqual(result.vw + 1);
expect(result.scrollWidthDoc).toBeLessThanOrEqual(result.vw + 1);
expect(result.scOverflow).toBeLessThanOrEqual(1);

// The expanded panel's bounding box must sit within the viewport horizontally
const viewport = page.viewportSize();
const viewportWidth = viewport?.width ?? result.vw;
const box = await firstPanel.boundingBox();
expect(box, 'Expected expanded panel to have a bounding box').not.toBeNull();
if (box) {
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(viewportWidth + 1);
}
});

test('expanded card collapses when clicking anywhere, including the expanded panel', async ({ page }) => {
await registerMocks(page, {
authenticated: true,
customProfiles: [{ id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }]
});
const now = new Date().toISOString();
const items = [
{
profile_id: 'prof1', timestamp: now, status: 'blocked', protocol: 'dns',
device_id: 'd1', client_ip: '10.0.0.1',
dns_request: { domain: 'blocked.example.test' },
reasons: ['blocklist: some-blocklist-id']
}
];
await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(items) });
});

await page.goto('/query-logs');
await page.getByTestId('logs-scroll-container').first().waitFor({ state: 'attached', timeout: 10000 });

const toggle = page.getByTestId('querylog-card-toggle').first();
const panel = page.getByTestId('querylog-expanded-panel').first();

// Expand.
await toggle.click();
await expect(panel).toHaveAttribute('data-expanded', 'true');

// Click inside the EXPANDED PANEL region (below the header) — must collapse the card.
const box = await panel.boundingBox();
expect(box, 'Expected an expanded panel bounding box').not.toBeNull();
if (box) {
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
}
await expect(panel).toHaveAttribute('data-expanded', 'false');
});

test('mobile: one-time expand hint shows, dismisses after first expand, and stays gone', async ({ page }, testInfo) => {
// The hint is mobile-only (md:hidden); skip on desktop projects.
test.skip(!/(chromium-mobile|iphone15pro)/i.test(testInfo.project.name), 'mobile-only hint');

await registerMocks(page, {
authenticated: true,
customProfiles: [{ id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }]
});
const now = new Date().toISOString();
const items = [
{ profile_id: 'prof1', timestamp: now, status: 'processed', protocol: 'dns', device_id: 'd1', client_ip: '10.0.0.1', dns_request: { domain: 'a.example.test' } },
{ profile_id: 'prof1', timestamp: now, status: 'blocked', protocol: 'dns', device_id: 'd2', client_ip: '10.0.0.2', dns_request: { domain: 'b.example.test' } }
];
await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(items) });
});

await page.goto('/query-logs');
await page.getByTestId('logs-scroll-container').first().waitFor({ state: 'attached', timeout: 10000 });

// Hint is visible on first visit.
const hint = page.getByTestId('logs-expand-hint');
await expect(hint).toBeVisible();

// Expanding a row dismisses the hint.
await page.getByTestId('querylog-card-toggle').first().click();
await expect(hint).toHaveCount(0);

// Persisted: reload keeps it gone.
await page.reload();
await page.getByTestId('logs-scroll-container').first().waitFor({ state: 'attached', timeout: 10000 });
await expect(page.getByTestId('logs-expand-hint')).toHaveCount(0);
});
});
166 changes: 153 additions & 13 deletions app/src/__tests__/unit/QueryLogCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function stubDesktopMatchMedia(isDesktop: boolean) {
};
}

describe('QueryLogCard truncation interactions', () => {
describe('QueryLogCard truncation display', () => {
beforeEach(() => {
// Reset viewport width
// Override viewport width for desktop simulation
Expand All @@ -49,9 +49,6 @@ describe('QueryLogCard truncation interactions', () => {
expect(fullEl).toHaveTextContent(deviceId);
expect(fullEl.textContent).toHaveLength(deviceId.length);
expect(fullEl.textContent?.endsWith('…')).toBeFalsy();
// Tooltip still present wrapping element; hover should not change content
fireEvent.mouseEnter(fullEl);
expect(fullEl).toHaveTextContent(deviceId);
});

test('desktop domain display strips trailing dot', () => {
Expand All @@ -71,7 +68,7 @@ describe('QueryLogCard truncation interactions', () => {
expect(domainSpan).not.toHaveTextContent(/\.$/);
});

test('mobile tap expands truncated domain (threshold 65)', () => {
test('mobile renders a static truncated domain span (no tap-to-reveal)', () => {
stubDesktopMatchMedia(false);
// Override viewport width for mobile simulation
(window as unknown as { innerWidth: number }).innerWidth = 375;
Expand All @@ -87,13 +84,157 @@ describe('QueryLogCard truncation interactions', () => {
dns_request: { domain: longDomain }
};
render(<QueryLogCard log={log} />);
const truncatedDomainBtn = screen.getByTestId('querylog-domain-truncated');
expect(truncatedDomainBtn).toBeInTheDocument();
// Verify it contains ellipsis at end
expect(truncatedDomainBtn.textContent).toMatch(/…$/);
fireEvent.click(truncatedDomainBtn);
const fullDomainSpan = screen.getByTestId('querylog-domain-full');
expect(fullDomainSpan).toHaveTextContent(longDomain);
const truncatedDomain = screen.getByTestId('querylog-domain-truncated');
expect(truncatedDomain).toBeInTheDocument();
// Static truncated text ends with an ellipsis; it is a plain span (not a button).
expect(truncatedDomain.textContent).toMatch(/…$/);
expect(truncatedDomain.tagName).toBe('SPAN');
});
});

describe('QueryLogCard whole-card expansion', () => {
beforeEach(() => {
(window as unknown as { innerWidth: number }).innerWidth = 1440;
stubDesktopMatchMedia(true);
});

const baseLog: ModelQueryLog = {
profile_id: 'p-exp',
timestamp: '2026-06-15T10:20:30.000Z',
status: 'processed',
protocol: 'dns',
device_id: 'expand-device',
client_ip: '10.0.0.9',
dns_request: { domain: 'expand.example.com', query_type: 'A', response_code: 'NOERROR', dnssec: true }
};

test('renders the whole-card toggle', () => {
render(<QueryLogCard log={baseLog} />);
expect(screen.getByTestId('querylog-card-toggle')).toBeInTheDocument();
});

test('clicking the toggle flips the expanded panel state', () => {
render(<QueryLogCard log={baseLog} />);
const toggle = screen.getByTestId('querylog-card-toggle');
const panel = screen.getByTestId('querylog-expanded-panel');
expect(panel).toHaveAttribute('data-expanded', 'false');
expect(toggle).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(toggle);
expect(panel).toHaveAttribute('data-expanded', 'true');
expect(toggle).toHaveAttribute('aria-expanded', 'true');
});

test('expanded panel shows the detail grid with protocol and timestamp', () => {
render(<QueryLogCard log={baseLog} />);
fireEvent.click(screen.getByTestId('querylog-card-toggle'));
expect(screen.getByTestId('querylog-detail-grid')).toBeInTheDocument();
expect(screen.getByTestId('querylog-detail-protocol')).toHaveTextContent('DNS');
expect(screen.getByTestId('querylog-detail-timestamp')).toBeInTheDocument();
});

test('row with reasons renders the reasons block', () => {
const log: ModelQueryLog = {
...baseLog,
status: 'blocked',
reasons: ['blocklist: some-blocklist-id']
};
render(<QueryLogCard log={log} />);
fireEvent.click(screen.getByTestId('querylog-card-toggle'));
expect(screen.getByTestId('querylog-reasons')).toBeInTheDocument();
});

test('row without reasons omits the reasons block but still expands', () => {
render(<QueryLogCard log={baseLog} />);
fireEvent.click(screen.getByTestId('querylog-card-toggle'));
expect(screen.getByTestId('querylog-detail-grid')).toBeInTheDocument();
expect(screen.queryByTestId('querylog-reasons')).not.toBeInTheDocument();
});

test('domain-logging-disabled row is still expandable and shows a placeholder', () => {
const log: ModelQueryLog = {
...baseLog,
dns_request: undefined as unknown as ModelQueryLog['dns_request']
};
render(<QueryLogCard log={log} />);
fireEvent.click(screen.getByTestId('querylog-card-toggle'));
expect(screen.getByTestId('querylog-detail-domain')).toHaveTextContent('Domain logging disabled');
});

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

test('onExpand fires only when expanding (not when collapsing)', () => {
const onExpand = vi.fn();
render(<QueryLogCard log={baseLog} onExpand={onExpand} />);
const toggle = screen.getByTestId('querylog-card-toggle');
fireEvent.click(toggle); // expand
expect(onExpand).toHaveBeenCalledTimes(1);
fireEvent.click(toggle); // collapse
expect(onExpand).toHaveBeenCalledTimes(1);
});

test('shows the DNSSEC badge on the collapsed row when validated', () => {
render(<QueryLogCard log={baseLog} />); // baseLog has dns_request.dnssec === true
expect(screen.getByTestId('querylog-dnssec-badge')).toHaveTextContent('DNSSEC');
});

test('omits the DNSSEC badge when neither validated nor failed', () => {
const log: ModelQueryLog = {
...baseLog,
dns_request: { ...baseLog.dns_request, dnssec: false }
};
render(<QueryLogCard log={log} />);
expect(screen.queryByTestId('querylog-dnssec-badge')).not.toBeInTheDocument();
});

test('shows a red (failed) DNSSEC badge when validation failed', () => {
const log: ModelQueryLog = {
...baseLog,
status: 'processed',
dns_request: { ...baseLog.dns_request, dnssec: false, response_code: 'SERVFAIL' },
reasons: ['dnssec_failed'],
};
render(<QueryLogCard log={log} />);
const badge = screen.getByTestId('querylog-dnssec-badge');
expect(badge).toHaveTextContent('DNSSEC');
expect(badge).toHaveAttribute('data-dnssec', 'failed');
});

test('labels the reason "Block reason" for a DNSSEC-failed row (not "Allow reason")', () => {
const log: ModelQueryLog = {
...baseLog,
status: 'processed',
dns_request: { ...baseLog.dns_request, dnssec: false, response_code: 'SERVFAIL' },
reasons: ['dnssec_failed'],
};
render(<QueryLogCard log={log} />);
fireEvent.click(screen.getByTestId('querylog-card-toggle'));
const reasons = screen.getByTestId('querylog-reasons');
expect(reasons).toHaveTextContent('Block reason');
expect(reasons).not.toHaveTextContent('Allow reason');
});

test('DNSSEC detail field distinguishes the three states', () => {
const detailText = (log: ModelQueryLog) => {
const { unmount } = render(<QueryLogCard log={log} />);
fireEvent.click(screen.getByTestId('querylog-card-toggle'));
const text = screen.getByTestId('querylog-detail-dnssec').textContent;
unmount();
return text;
};
// validated
expect(detailText(baseLog)).toBe('Validated');
// unsigned (dnssec false, no failure reason)
expect(detailText({ ...baseLog, dns_request: { ...baseLog.dns_request, dnssec: false } })).toBe('No DNSSEC');
// failed (bogus)
expect(detailText({
...baseLog,
status: 'processed',
dns_request: { ...baseLog.dns_request, dnssec: false, response_code: 'SERVFAIL' },
reasons: ['dnssec_failed'],
})).toBe('Validation failed');
});
});

Expand Down Expand Up @@ -141,4 +282,3 @@ describe('QueryLogCard quick rule button', () => {
expect(onQuickRule).not.toHaveBeenCalled();
});
});

Loading
Loading