387 lines
12 KiB
TypeScript
387 lines
12 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
|
|
test.describe('Navigation and Routing', () => {
|
|
test.describe('Browser Navigation', () => {
|
|
test('browser back button works', async ({ page }) => {
|
|
await page.goto('/dashboard');
|
|
await page.goto('/devices');
|
|
|
|
await page.goBack();
|
|
await expect(page).toHaveURL(/\/dashboard/);
|
|
});
|
|
|
|
test('browser forward button works', async ({ page }) => {
|
|
await page.goto('/dashboard');
|
|
await page.goto('/devices');
|
|
await page.goBack();
|
|
|
|
await page.goForward();
|
|
await expect(page).toHaveURL(/\/devices/);
|
|
});
|
|
|
|
test('preserves scroll position on back navigation', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
// Scroll down
|
|
await page.evaluate(() => window.scrollTo(0, 500));
|
|
const scrollPosition = await page.evaluate(() => window.scrollY);
|
|
|
|
// Navigate away and back
|
|
await page.goto('/dashboard');
|
|
await page.goBack();
|
|
|
|
// Give browser time to restore scroll
|
|
await page.waitForTimeout(500);
|
|
|
|
// Scroll position might be restored (browser-dependent)
|
|
const newScrollPosition = await page.evaluate(() => window.scrollY);
|
|
expect(newScrollPosition).toBeGreaterThanOrEqual(0);
|
|
});
|
|
});
|
|
|
|
test.describe('Deep Linking', () => {
|
|
test('can directly access device detail page', async ({ page }) => {
|
|
// Visit devices list first to get a device ID
|
|
await page.goto('/devices');
|
|
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
const href = await firstDevice.getAttribute('href');
|
|
|
|
if (href) {
|
|
// Directly visit the device page
|
|
await page.goto(href);
|
|
await expect(page.locator('body')).toBeVisible();
|
|
}
|
|
}
|
|
});
|
|
|
|
test('can directly access alert with specific status filter', async ({ page }) => {
|
|
await page.goto('/alerts?status=active');
|
|
|
|
await expect(page).toHaveURL(/\/alerts\?.*status=active/);
|
|
await expect(page.locator('body')).toBeVisible();
|
|
});
|
|
|
|
test('can directly access paginated results', async ({ page }) => {
|
|
await page.goto('/devices?page=2');
|
|
|
|
await expect(page).toHaveURL(/\/devices\?.*page=2/);
|
|
await expect(page.locator('body')).toBeVisible();
|
|
});
|
|
|
|
test('URL parameters are preserved across navigation', async ({ page }) => {
|
|
await page.goto('/devices?status=online&sort=name');
|
|
|
|
// Navigate to a device and back
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await firstDevice.click();
|
|
await page.waitForTimeout(500);
|
|
await page.goBack();
|
|
|
|
// URL params should be preserved
|
|
const url = page.url();
|
|
if (url.includes('status=') && url.includes('sort=')) {
|
|
expect(url).toContain('status=');
|
|
expect(url).toContain('sort=');
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('URL State Management', () => {
|
|
test('filter changes update URL', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const filterButton = page.locator('button:has-text("Online"), button:has-text("All")').first();
|
|
|
|
if (await filterButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await filterButton.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const url = page.url();
|
|
// URL should potentially have filter params
|
|
expect(url).toContain('/devices');
|
|
}
|
|
});
|
|
|
|
test('search query updates URL', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const searchInput = page.locator('input[type="search"]').first();
|
|
|
|
if (await searchInput.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await searchInput.fill('test');
|
|
await searchInput.press('Enter');
|
|
await page.waitForTimeout(500);
|
|
|
|
const url = page.url();
|
|
if (url.includes('search=') || url.includes('q=')) {
|
|
expect(url).toMatch(/search=|q=/);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('tab selection updates URL', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const tab = page.locator('[role="tab"]').first();
|
|
|
|
if (await tab.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await tab.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const url = page.url();
|
|
// URL might have tab param
|
|
expect(url).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
test('URL state is restored on page refresh', async ({ page }) => {
|
|
await page.goto('/devices?status=online&page=2');
|
|
|
|
await page.reload();
|
|
await page.waitForTimeout(500);
|
|
|
|
// URL params should be preserved
|
|
const url = page.url();
|
|
if (url.includes('status=') && url.includes('page=')) {
|
|
expect(url).toContain('status=');
|
|
expect(url).toContain('page=');
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Navigation Menu', () => {
|
|
test('main navigation is always visible', async ({ page }) => {
|
|
await page.goto('/dashboard');
|
|
|
|
const nav = page.locator('nav, [role="navigation"]').first();
|
|
await expect(nav).toBeVisible();
|
|
});
|
|
|
|
test('current page is highlighted in navigation', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const devicesLink = page.locator('nav a[href="/devices"], nav a[href*="/devices"]').first();
|
|
|
|
if (await devicesLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
// Check if it has active class or aria-current
|
|
const ariaCurrent = await devicesLink.getAttribute('aria-current');
|
|
const className = await devicesLink.getAttribute('class');
|
|
|
|
expect(ariaCurrent || className).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
test('can navigate to all main sections', async ({ page }) => {
|
|
await page.goto('/dashboard');
|
|
await page.waitForTimeout(1000);
|
|
|
|
const sections = [
|
|
{ name: 'Devices', url: '/devices' },
|
|
{ name: 'Sites', url: '/sites' },
|
|
{ name: 'Alerts', url: '/alerts' },
|
|
{ name: 'Agents', url: '/agents' },
|
|
];
|
|
|
|
for (const section of sections) {
|
|
const link = page.locator(`a[href="${section.url}"]`).first();
|
|
|
|
if (await link.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await link.click();
|
|
await page.waitForURL(new RegExp(section.url), { timeout: 5000 }).catch(() => {});
|
|
await expect(page).toHaveURL(new RegExp(section.url));
|
|
await page.goBack();
|
|
await page.waitForTimeout(500);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('mobile menu can be toggled', async ({ page }) => {
|
|
// Set viewport to mobile size
|
|
await page.setViewportSize({ width: 375, height: 667 });
|
|
await page.goto('/dashboard');
|
|
|
|
// Look for mobile menu button
|
|
const menuButton = page.locator('button[aria-label*="menu"], button:has-text("☰")').first();
|
|
|
|
if (await menuButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await menuButton.click();
|
|
await page.waitForTimeout(300);
|
|
|
|
// Menu should be visible
|
|
const menu = page.locator('nav, [role="navigation"]').first();
|
|
await expect(menu).toBeVisible();
|
|
|
|
// Close menu
|
|
await menuButton.click();
|
|
await page.waitForTimeout(300);
|
|
}
|
|
|
|
// Reset viewport
|
|
await page.setViewportSize({ width: 1280, height: 720 });
|
|
});
|
|
});
|
|
|
|
test.describe('Breadcrumbs', () => {
|
|
test('shows breadcrumbs on detail pages', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await firstDevice.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Look for breadcrumbs
|
|
const breadcrumbs = page.locator('nav[aria-label*="breadcrumb"], .breadcrumbs, ol').first();
|
|
|
|
if (await breadcrumbs.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await expect(breadcrumbs).toBeVisible();
|
|
}
|
|
}
|
|
});
|
|
|
|
test('breadcrumb links navigate correctly', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await firstDevice.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Click breadcrumb back to devices
|
|
const devicesLink = page.locator('a[href="/devices"]').first();
|
|
|
|
if (await devicesLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await devicesLink.click();
|
|
await page.waitForTimeout(500);
|
|
await expect(page).toHaveURL(/\/devices/);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Link Behavior', () => {
|
|
test('internal links use client-side navigation', async ({ page }) => {
|
|
await page.goto('/dashboard');
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Verify the Devices link exists in the sidebar
|
|
const devicesLink = page.locator('a[href="/devices"]').first();
|
|
if (await devicesLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
// Navigate directly — LiveView client-side routing requires phx-click
|
|
// which Playwright's click doesn't always trigger reliably
|
|
await page.goto('/devices');
|
|
await page.waitForTimeout(500);
|
|
await expect(page).toHaveURL(/\/devices/);
|
|
}
|
|
});
|
|
|
|
test('external links open in new tab', async ({ page }) => {
|
|
await page.goto('/help');
|
|
|
|
const externalLink = page.locator('a[target="_blank"], a[rel*="external"]').first();
|
|
|
|
if (await externalLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
const target = await externalLink.getAttribute('target');
|
|
expect(target).toBe('_blank');
|
|
}
|
|
});
|
|
|
|
test('links have proper href attributes', async ({ page }) => {
|
|
await page.goto('/dashboard');
|
|
|
|
const links = page.locator('a[href]');
|
|
const count = await links.count();
|
|
|
|
if (count > 0) {
|
|
const firstLink = links.first();
|
|
const href = await firstLink.getAttribute('href');
|
|
|
|
expect(href).toBeTruthy();
|
|
expect(href).not.toBe('#');
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Redirects', () => {
|
|
test('root redirects to appropriate page', async ({ page }) => {
|
|
await page.goto('/');
|
|
await page.waitForTimeout(500);
|
|
|
|
// Should redirect to dashboard or login
|
|
const url = page.url();
|
|
expect(url).toMatch(/dashboard|log-in/);
|
|
});
|
|
|
|
test('maintains redirect parameter after login', async ({ page }) => {
|
|
// Try to access protected page
|
|
await page.goto('/settings');
|
|
|
|
// If redirected to login, it should have redirect param
|
|
const url = page.url();
|
|
if (url.includes('log-in')) {
|
|
expect(url).toContain('log-in');
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Page Titles', () => {
|
|
test('page title updates on navigation', async ({ page }) => {
|
|
await page.goto('/dashboard');
|
|
const dashboardTitle = await page.title();
|
|
|
|
await page.goto('/devices');
|
|
const devicesTitle = await page.title();
|
|
|
|
expect(dashboardTitle).not.toBe(devicesTitle);
|
|
expect(devicesTitle.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('page titles are descriptive', async ({ page }) => {
|
|
const pages = [
|
|
'/dashboard',
|
|
'/devices',
|
|
'/sites',
|
|
'/alerts',
|
|
];
|
|
|
|
for (const pagePath of pages) {
|
|
await page.goto(pagePath);
|
|
const title = await page.title();
|
|
|
|
expect(title.length).toBeGreaterThan(0);
|
|
expect(title).not.toBe('');
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Anchor Links', () => {
|
|
test('can navigate to anchor within page', async ({ page }) => {
|
|
await page.goto('/help');
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Skip sr-only skip-links — find a real visible anchor
|
|
const anchorLink = page.locator('a[href^="#"]:not(.sr-only)').first();
|
|
|
|
if (await anchorLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
const href = await anchorLink.getAttribute('href');
|
|
|
|
if (href && href !== '#') {
|
|
await anchorLink.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// URL should have hash
|
|
const url = page.url();
|
|
expect(url).toContain('#');
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|