feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
29
frontend/tests/e2e/alerts.spec.ts
Normal file
29
frontend/tests/e2e/alerts.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from './fixtures'
|
||||
|
||||
test.describe('Alerts Page', () => {
|
||||
test('should display alerts page content', async ({ page }) => {
|
||||
await page.goto('/alerts')
|
||||
// Should show alerts content or empty state
|
||||
await page.waitForTimeout(2000)
|
||||
const hasAlerts = (await page.locator('table').count()) > 0
|
||||
const hasEmptyState =
|
||||
(await page.getByText(/no active alerts|all clear|no alerts|select an organization/i).count()) > 0
|
||||
const hasHeading = (await page.getByText(/alerts/i).count()) > 0
|
||||
expect(hasAlerts || hasEmptyState || hasHeading).toBe(true)
|
||||
})
|
||||
|
||||
test('should navigate back to dashboard from alerts', async ({ page }) => {
|
||||
await page.goto('/alerts')
|
||||
// Click dashboard link in sidebar
|
||||
await page.getByRole('link', { name: /dashboard|fleet/i }).first().click()
|
||||
await expect(page).toHaveURL(/^\/$|\/tenants/)
|
||||
})
|
||||
|
||||
test('should stay authenticated on alerts page', async ({ page }) => {
|
||||
await page.goto('/alerts')
|
||||
// Should not redirect to login
|
||||
await expect(page).not.toHaveURL(/login/)
|
||||
// Sidebar should be visible (authenticated layout)
|
||||
await expect(page.locator('nav').first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
33
frontend/tests/e2e/auth.setup.ts
Normal file
33
frontend/tests/e2e/auth.setup.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { test as setup, expect } from '@playwright/test'
|
||||
|
||||
const authFile = 'tests/e2e/.auth/user.json'
|
||||
|
||||
setup('authenticate', async ({ page }) => {
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:5173'
|
||||
await page.goto(`${baseURL}/login`)
|
||||
|
||||
// Use legacy-auth test user (no SRP/Secret Key required)
|
||||
await page.getByLabel(/email/i).fill(
|
||||
process.env.TEST_ADMIN_EMAIL || 'e2e-test@mikrotik-portal.dev'
|
||||
)
|
||||
await page.getByLabel(/password/i).fill(
|
||||
process.env.TEST_ADMIN_PASSWORD || 'admin123'
|
||||
)
|
||||
await page.getByRole('button', { name: /sign in/i }).click()
|
||||
|
||||
// Legacy auth user may trigger SRP upgrade dialog -- dismiss if present
|
||||
const upgradeDialog = page.getByRole('dialog')
|
||||
if (await upgradeDialog.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
// Skip/cancel the SRP upgrade for E2E testing
|
||||
const skipButton = page.getByRole('button', { name: /skip|cancel|later|close/i })
|
||||
if (await skipButton.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await skipButton.click()
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for redirect to dashboard (/ or /tenants/...)
|
||||
await expect(page).toHaveURL(/\/$|\/tenants/, { timeout: 15000 })
|
||||
|
||||
// Save storage state (cookies + localStorage) for reuse across tests
|
||||
await page.context().storageState({ path: authFile })
|
||||
})
|
||||
41
frontend/tests/e2e/dashboard.spec.ts
Normal file
41
frontend/tests/e2e/dashboard.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from './fixtures'
|
||||
import { DashboardPage } from './pages/dashboard.page'
|
||||
|
||||
test.describe('Fleet Dashboard', () => {
|
||||
test('should display dashboard after login', async ({ page }) => {
|
||||
const dashboard = new DashboardPage(page)
|
||||
await dashboard.goto()
|
||||
// Dashboard should load without redirecting to login
|
||||
await expect(page).not.toHaveURL(/login/)
|
||||
// Should see sidebar navigation
|
||||
await expect(dashboard.sidebar).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show KPI cards or empty state', async ({ page }) => {
|
||||
const dashboard = new DashboardPage(page)
|
||||
await dashboard.goto()
|
||||
// Wait for content to load (either KPI cards or empty state)
|
||||
await page.waitForTimeout(2000)
|
||||
const hasCards = (await dashboard.kpiCards.count()) > 0
|
||||
const hasEmptyState =
|
||||
(await page.getByText(/no devices|no fleet|add your first/i).count()) > 0
|
||||
expect(hasCards || hasEmptyState).toBe(true)
|
||||
})
|
||||
|
||||
test('should have working navigation sidebar', async ({ page }) => {
|
||||
const dashboard = new DashboardPage(page)
|
||||
await dashboard.goto()
|
||||
// Click on Alerts nav item in sidebar
|
||||
await page.getByRole('link', { name: /alerts/i }).first().click()
|
||||
await expect(page).toHaveURL(/alerts/)
|
||||
})
|
||||
|
||||
test('should show Fleet heading or dashboard content', async ({ page }) => {
|
||||
const dashboard = new DashboardPage(page)
|
||||
await dashboard.goto()
|
||||
// Dashboard should render meaningful content
|
||||
await page.waitForTimeout(1000)
|
||||
const headingCount = await dashboard.heading.count()
|
||||
expect(headingCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
39
frontend/tests/e2e/device.spec.ts
Normal file
39
frontend/tests/e2e/device.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from './fixtures'
|
||||
|
||||
test.describe('Device Navigation', () => {
|
||||
test('should navigate to device list from dashboard', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
// Click Devices in sidebar
|
||||
await page.getByRole('link', { name: /devices/i }).first().click()
|
||||
// Super_admin may land on /tenants (org picker) or /devices depending on tenant context
|
||||
await expect(page).toHaveURL(/devices|tenants/, { timeout: 5000 })
|
||||
// Look for device table, org list, or empty state
|
||||
await page.waitForTimeout(2000)
|
||||
const hasTable = (await page.locator('table').count()) > 0
|
||||
const hasEmptyState =
|
||||
(await page.getByText(/no devices|add your first|select an organization|organizations/i).count()) > 0
|
||||
expect(hasTable || hasEmptyState).toBe(true)
|
||||
})
|
||||
|
||||
test('should open command palette with Cmd+K', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(1000) // Wait for app to fully load
|
||||
// Open command palette
|
||||
await page.keyboard.press('Meta+k')
|
||||
// Command palette dialog should appear
|
||||
const dialog = page.locator('[cmdk-dialog], [role="dialog"]')
|
||||
await expect(dialog.first()).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('should close command palette with Escape', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(1000)
|
||||
// Open command palette
|
||||
await page.keyboard.press('Meta+k')
|
||||
const dialog = page.locator('[cmdk-dialog], [role="dialog"]')
|
||||
await expect(dialog.first()).toBeVisible({ timeout: 3000 })
|
||||
// Close with Escape
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog.first()).not.toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
})
|
||||
12
frontend/tests/e2e/fixtures.ts
Normal file
12
frontend/tests/e2e/fixtures.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Extended test fixture that uses the authenticated session
|
||||
* from auth.setup.ts. All tests importing from this file
|
||||
* will run as the logged-in admin user.
|
||||
*/
|
||||
export const test = base.extend({
|
||||
storageState: 'tests/e2e/.auth/user.json',
|
||||
})
|
||||
|
||||
export { expect } from '@playwright/test'
|
||||
58
frontend/tests/e2e/login.spec.ts
Normal file
58
frontend/tests/e2e/login.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { LoginPage } from './pages/login.page'
|
||||
|
||||
// Login tests must run WITHOUT stored auth state
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
|
||||
test.describe('Login Flow', () => {
|
||||
test('should show login page with email and password fields', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page)
|
||||
await loginPage.goto()
|
||||
await expect(loginPage.emailInput).toBeVisible()
|
||||
await expect(loginPage.passwordInput).toBeVisible()
|
||||
await expect(loginPage.submitButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show error on invalid credentials', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page)
|
||||
await loginPage.goto()
|
||||
await loginPage.login('wrong@example.com', 'wrongpassword')
|
||||
// Should stay on login page or show error
|
||||
await page.waitForTimeout(3000)
|
||||
const onLogin = page.url().includes('/login')
|
||||
const hasError = (await page.locator('[data-testid="login-error"]').count()) > 0
|
||||
expect(onLogin || hasError).toBe(true)
|
||||
})
|
||||
|
||||
test('should redirect to dashboard on valid login', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page)
|
||||
await loginPage.goto()
|
||||
await loginPage.login(
|
||||
process.env.TEST_ADMIN_EMAIL || 'e2e-test@mikrotik-portal.dev',
|
||||
process.env.TEST_ADMIN_PASSWORD || 'admin123'
|
||||
)
|
||||
// Legacy auth user may trigger SRP upgrade dialog -- handle it
|
||||
const upgradeDialog = page.getByRole('dialog')
|
||||
if (await upgradeDialog.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
const skipButton = page.getByRole('button', { name: /skip|cancel|later|close/i })
|
||||
if (await skipButton.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await skipButton.click()
|
||||
}
|
||||
}
|
||||
// Should redirect away from login after successful auth
|
||||
await expect(page).not.toHaveURL(/login/, { timeout: 15000 })
|
||||
})
|
||||
|
||||
test('should display TOD branding', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page)
|
||||
await loginPage.goto()
|
||||
await expect(page.getByText('TOD - The Other Dude')).toBeVisible()
|
||||
await expect(page.getByText('MSP Fleet Management')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should disable submit button when fields are empty', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page)
|
||||
await loginPage.goto()
|
||||
await expect(loginPage.submitButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
29
frontend/tests/e2e/pages/dashboard.page.ts
Normal file
29
frontend/tests/e2e/pages/dashboard.page.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Page Object Model for the fleet dashboard (/).
|
||||
* Encapsulates selectors for dashboard elements.
|
||||
*/
|
||||
export class DashboardPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/')
|
||||
}
|
||||
|
||||
get heading() {
|
||||
return this.page.getByRole('heading').first()
|
||||
}
|
||||
|
||||
get sidebar() {
|
||||
return this.page.locator('nav').first()
|
||||
}
|
||||
|
||||
get deviceRows() {
|
||||
return this.page.locator('table tbody tr, [role="row"]')
|
||||
}
|
||||
|
||||
get kpiCards() {
|
||||
return this.page.locator('[class*="card"], [class*="Card"]')
|
||||
}
|
||||
}
|
||||
35
frontend/tests/e2e/pages/login.page.ts
Normal file
35
frontend/tests/e2e/pages/login.page.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Page Object Model for the login page (/login).
|
||||
* Encapsulates selectors and actions for login form interaction.
|
||||
*/
|
||||
export class LoginPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/login')
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
await this.page.getByLabel(/email/i).fill(email)
|
||||
await this.page.getByLabel(/password/i).fill(password)
|
||||
await this.page.getByRole('button', { name: /sign in/i }).click()
|
||||
}
|
||||
|
||||
get emailInput() {
|
||||
return this.page.getByLabel(/email/i)
|
||||
}
|
||||
|
||||
get passwordInput() {
|
||||
return this.page.getByLabel(/password/i)
|
||||
}
|
||||
|
||||
get submitButton() {
|
||||
return this.page.getByRole('button', { name: /sign in/i })
|
||||
}
|
||||
|
||||
get errorMessage() {
|
||||
return this.page.locator('.text-error')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user