feat(admin): Playwright E2E каркас — TESTIDS, login/shell/roles, CI mock. Refs EventHub/EventHubFrontAdmin#33 #34 #35
This commit is contained in:
@@ -47,6 +47,12 @@ jobs:
|
||||
- run: npm run lint
|
||||
- run: npm run build
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: E2E mock suite
|
||||
run: npm run test:e2e
|
||||
|
||||
- name: Upload dist for docker job
|
||||
if: github.event_name == 'push'
|
||||
uses: actions/upload-artifact@v3
|
||||
@@ -55,6 +61,14 @@ jobs:
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Mark test complete
|
||||
id: mark_test
|
||||
if: success()
|
||||
|
||||
@@ -11,6 +11,10 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
playwright-report
|
||||
test-results
|
||||
blob-report
|
||||
.playwright
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# E2E testid contract (EventHubFrontAdmin)
|
||||
|
||||
Селекторы: **`data-testid`** + a11y (`getByRole` / `getByLabel`) где имя стабильно.
|
||||
|
||||
## Naming
|
||||
|
||||
| Pattern | Example |
|
||||
|---------|---------|
|
||||
| `page-{name}` | `page-login`, `page-users` |
|
||||
| `nav-{slug}` | `nav-users` (slug = path without `/`) |
|
||||
| `{entity}-row-{id}` | `user-row-{uuid}` |
|
||||
| `{entity}-detail` | `user-detail` |
|
||||
| `{entity}-filter-{field}` | `event-filter-status` |
|
||||
| `btn-{action}` | `btn-login`, `btn-save`, `btn-delete-confirm` |
|
||||
| `dialog-{name}` | `dialog-edit-user` |
|
||||
| `menu-user` | avatar dropdown |
|
||||
| `palette-input` | command palette |
|
||||
|
||||
Helper in app: `src/lib/testid.ts` → `testid(['user-row', id])`.
|
||||
|
||||
## Rules
|
||||
|
||||
- kebab-case only
|
||||
- Dynamic id segment = real API entity id
|
||||
- Prefer roles/labels for login email/password and primary submit when i18n fixed to `ru` in mock suite
|
||||
- Do not decorate every layout div — only interaction anchors
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { MOCK_ADMIN } from '../mocks/adminApi';
|
||||
|
||||
export async function loginAsE2EAdmin(page: Page) {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel(/email/i).fill(MOCK_ADMIN.email);
|
||||
await page.getByLabel(/пароль|password/i).fill('e2e-pass');
|
||||
await page.getByTestId('btn-login').click();
|
||||
await page.waitForURL(/\/(dashboard|inbox)/);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from '../../src/utils/constants';
|
||||
import { installAdminApiMocks, MOCK_ADMIN, type MockAdmin } from '../mocks/adminApi';
|
||||
|
||||
export async function seedAuthenticatedSession(page: Page, admin: Partial<MockAdmin> = {}) {
|
||||
const user: MockAdmin = { ...MOCK_ADMIN, ...admin };
|
||||
await installAdminApiMocks(page, { user });
|
||||
// Origin + localStorage before app boot (addInitScript alone races under load).
|
||||
await page.goto('/login');
|
||||
await page.evaluate(
|
||||
([accessKey, refreshKey]) => {
|
||||
localStorage.setItem(accessKey, 'e2e-access-token');
|
||||
localStorage.setItem(refreshKey, 'e2e-refresh-token');
|
||||
},
|
||||
[ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY] as [string, string]
|
||||
);
|
||||
return user;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
|
||||
export type MockAdmin = {
|
||||
id: string;
|
||||
email: string;
|
||||
nickname: string;
|
||||
role: string;
|
||||
language: string;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
|
||||
export const MOCK_ADMIN: MockAdmin = {
|
||||
id: 'admin-e2e-1',
|
||||
email: 'e2e@eventhub.local',
|
||||
nickname: 'E2E Admin',
|
||||
role: 'superadmin',
|
||||
language: 'ru',
|
||||
avatar_url: null,
|
||||
};
|
||||
|
||||
async function json(route: Route, status: number, body: unknown, headers: Record<string, string> = {}) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
type MockOptions = {
|
||||
user?: MockAdmin;
|
||||
};
|
||||
|
||||
/** Default admin API mocks for Control Center shell / login. */
|
||||
export async function installAdminApiMocks(page: Page, options: MockOptions = {}) {
|
||||
let currentUser: MockAdmin = { ...MOCK_ADMIN, ...options.user };
|
||||
|
||||
await page.route('**/v1/admin/**', async (route) => {
|
||||
const req = route.request();
|
||||
const method = req.method();
|
||||
const url = new URL(req.url());
|
||||
const path = url.pathname;
|
||||
|
||||
if (method === 'POST' && path.endsWith('/v1/admin/login')) {
|
||||
const payload = req.postDataJSON() as { email?: string; password?: string };
|
||||
if (payload?.email === MOCK_ADMIN.email && payload?.password === 'e2e-pass') {
|
||||
await json(route, 200, {
|
||||
token: 'e2e-access-token',
|
||||
refresh_token: 'e2e-refresh-token',
|
||||
user: currentUser,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await json(route, 401, { error: 'invalid_credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/v1/admin/me')) {
|
||||
await json(route, 200, currentUser);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'PUT' && path.endsWith('/v1/admin/me')) {
|
||||
const payload = (req.postDataJSON() || {}) as Partial<MockAdmin>;
|
||||
currentUser = { ...currentUser, ...payload };
|
||||
await json(route, 200, currentUser);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/v1/admin/health')) {
|
||||
await json(route, 200, {
|
||||
status: 'ok',
|
||||
service: 'eventhub',
|
||||
version: '0.1',
|
||||
git_sha: 'e2etestsha000',
|
||||
built_at: '2026-07-14T00:00:00Z',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/v1/admin/stats')) {
|
||||
await json(route, 200, {
|
||||
users_total: 10,
|
||||
events_total: 5,
|
||||
reviews_total: 2,
|
||||
calendars_total: 3,
|
||||
tickets_total: 1,
|
||||
tickets_open: 1,
|
||||
reports_total: 2,
|
||||
reports_pending: 1,
|
||||
reports_reviewed: 1,
|
||||
avg_report_resolution_h: 1.5,
|
||||
avg_ticket_resolution_h: 2,
|
||||
events_by_day: [],
|
||||
registrations_by_day: [],
|
||||
reviews_by_day: [],
|
||||
reports_by_day: [],
|
||||
tickets_by_day: [],
|
||||
subscriptions_by_day: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.includes('/v1/admin/nodes/metrics')) {
|
||||
await json(route, 200, { nodes: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET') {
|
||||
await json(route, 200, [], { 'X-Total-Count': '0' });
|
||||
return;
|
||||
}
|
||||
|
||||
await json(route, 200, { ok: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { installAdminApiMocks, MOCK_ADMIN } from '../mocks/adminApi';
|
||||
|
||||
test.describe('auth / login (mock)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installAdminApiMocks(page);
|
||||
});
|
||||
|
||||
test('успешный вход ведёт на dashboard', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await expect(page.getByTestId('page-login')).toBeVisible();
|
||||
|
||||
await page.getByLabel(/email/i).fill(MOCK_ADMIN.email);
|
||||
await page.getByLabel(/пароль|password/i).fill('e2e-pass');
|
||||
await page.getByRole('button', { name: /войти|log in|sign in/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page.getByTestId('page-dashboard')).toBeVisible();
|
||||
});
|
||||
|
||||
test('неверный пароль показывает ошибку', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel(/email/i).fill(MOCK_ADMIN.email);
|
||||
await page.getByLabel(/пароль|password/i).fill('wrong');
|
||||
await page.getByTestId('btn-login').click();
|
||||
await expect(page.getByText(/неверный email или пароль|invalid/i)).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
/** IFT smoke project placeholder — filled in phase 6. */
|
||||
test.describe('IFT smoke', () => {
|
||||
test.skip(!process.env.E2E_ADMIN_EMAIL, 'Set E2E_ADMIN_EMAIL / E2E_ADMIN_PASSWORD for IFT');
|
||||
|
||||
test('placeholder', async () => {
|
||||
// Implemented in E2E phase 6
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { seedAuthenticatedSession } from '../helpers/session';
|
||||
import { loginAsE2EAdmin } from '../helpers/auth';
|
||||
import { installAdminApiMocks } from '../mocks/adminApi';
|
||||
|
||||
test.describe('shell / layout (mock)', () => {
|
||||
test('после логина видны sidebar, nav dashboard и user menu', async ({ page }) => {
|
||||
await installAdminApiMocks(page);
|
||||
await loginAsE2EAdmin(page);
|
||||
|
||||
await expect(page.getByTestId('layout-shell')).toBeVisible();
|
||||
await expect(page.getByTestId('layout-sidebar')).toBeVisible();
|
||||
await expect(page.getByTestId('nav-dashboard')).toBeVisible();
|
||||
await expect(page.getByTestId('nav-users')).toBeVisible();
|
||||
await expect(page.getByTestId('nav-admins')).toBeVisible();
|
||||
|
||||
await page.getByTestId('menu-user').click();
|
||||
await expect(page.getByTestId('menu-user-content')).toBeVisible();
|
||||
await expect(page.getByTestId('menu-profile')).toBeVisible();
|
||||
await expect(page.getByTestId('btn-logout')).toBeVisible();
|
||||
});
|
||||
|
||||
test('command palette открывается и навигирует', async ({ page }) => {
|
||||
await seedAuthenticatedSession(page);
|
||||
await page.goto('/dashboard');
|
||||
await expect(page.getByTestId('layout-shell')).toBeVisible();
|
||||
|
||||
await page.getByTestId('btn-open-palette').click();
|
||||
await expect(page.getByTestId('command-palette')).toBeVisible();
|
||||
await page.getByTestId('palette-input').fill('Пользователи');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page).toHaveURL(/\/users/);
|
||||
});
|
||||
|
||||
test('logout возвращает на login', async ({ page }) => {
|
||||
await seedAuthenticatedSession(page);
|
||||
await page.goto('/dashboard');
|
||||
await page.getByTestId('menu-user').click();
|
||||
await page.getByTestId('btn-logout').click();
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByTestId('page-login')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('shell / roles (mock)', () => {
|
||||
test('moderator: нет nav users, admins запрещён', async ({ page }) => {
|
||||
await seedAuthenticatedSession(page, { role: 'moderator', nickname: 'Mod' });
|
||||
await page.goto('/dashboard');
|
||||
await expect(page.getByTestId('nav-inbox-reports')).toBeVisible();
|
||||
await expect(page.getByTestId('nav-users')).toHaveCount(0);
|
||||
await expect(page.getByTestId('nav-admins')).toHaveCount(0);
|
||||
|
||||
await page.goto('/admins');
|
||||
await expect(page.getByTestId('page-forbidden')).toBeVisible();
|
||||
await page.getByTestId('btn-to-dashboard').click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
});
|
||||
|
||||
test('support: tickets inbox есть, reports inbox нет', async ({ page }) => {
|
||||
await seedAuthenticatedSession(page, { role: 'support', nickname: 'Support' });
|
||||
await page.goto('/dashboard');
|
||||
await expect(page.getByTestId('nav-inbox-tickets')).toBeVisible();
|
||||
await expect(page.getByTestId('nav-inbox-reports')).toHaveCount(0);
|
||||
|
||||
await page.goto('/inbox/reports');
|
||||
await expect(page.getByTestId('page-forbidden')).toBeVisible();
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -6,9 +6,10 @@ import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(['dist', 'playwright-report', 'test-results', 'e2e']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
ignores: ['playwright.config.ts'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
|
||||
Generated
+105
-1446
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -11,7 +11,9 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"build:ift": "npm run build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "playwright test --project=mock",
|
||||
"test:e2e:ift": "E2E_TARGET=ift playwright test --project=ift-smoke"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
@@ -48,6 +50,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const port = Number(process.env.E2E_PORT || 4173);
|
||||
const baseURL = process.env.E2E_BASE_URL || `http://127.0.0.1:${port}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e/tests',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 10_000 },
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
locale: 'ru-RU',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'mock',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
testIgnore: /ift\//,
|
||||
},
|
||||
{
|
||||
name: 'ift-smoke',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
baseURL: process.env.E2E_IFT_BASE_URL || 'https://admin-ui.ift.eventhub.local',
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
testMatch: /ift\//,
|
||||
},
|
||||
],
|
||||
webServer:
|
||||
process.env.E2E_TARGET === 'ift'
|
||||
? undefined
|
||||
: {
|
||||
command: `npm run preview -- --host 127.0.0.1 --port ${port}`,
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
@@ -121,11 +121,16 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-lg" aria-describedby={undefined}>
|
||||
<DialogContent
|
||||
data-testid="command-palette"
|
||||
className="gap-0 overflow-hidden p-0 sm:max-w-lg"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<DialogTitle className="sr-only">{t('layout.paletteTitle')}</DialogTitle>
|
||||
<div className="flex items-center border-b px-3">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
data-testid="palette-input"
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from '@/config/adminAccess';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { testid } from '@/lib/testid';
|
||||
import { getTableDensity, toggleTableDensity, type TableDensity } from '@/lib/density';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
@@ -76,6 +77,10 @@ const navIconByKey: Record<string, React.ComponentType<{ className?: string }>>
|
||||
'/monitoring': Activity,
|
||||
};
|
||||
|
||||
function navTestId(path: string): string {
|
||||
return testid(['nav', ...path.split('/').filter(Boolean)]);
|
||||
}
|
||||
|
||||
function NavLinkItem({ item, active, collapsed }: { item: NavItem; active: boolean; collapsed: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = navIconByKey[item.key] ?? LayoutDashboard;
|
||||
@@ -83,6 +88,7 @@ function NavLinkItem({ item, active, collapsed }: { item: NavItem; active: boole
|
||||
return (
|
||||
<Link
|
||||
to={item.key}
|
||||
data-testid={navTestId(item.key)}
|
||||
title={collapsed ? label : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors',
|
||||
@@ -206,8 +212,9 @@ const ControlCenterLayout: React.FC = () => {
|
||||
const initials = displayName.slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<div data-testid="layout-shell" className="flex min-h-screen bg-background">
|
||||
<aside
|
||||
data-testid="layout-sidebar"
|
||||
className={cn(
|
||||
'flex flex-col border-r bg-[hsl(var(--sidebar))] text-[hsl(var(--sidebar-foreground))] transition-[width]',
|
||||
collapsed ? 'w-16' : 'w-60'
|
||||
@@ -261,6 +268,7 @@ const ControlCenterLayout: React.FC = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="btn-open-palette"
|
||||
className="hidden sm:inline-flex gap-2 text-muted-foreground"
|
||||
onClick={openPalette}
|
||||
>
|
||||
@@ -289,7 +297,7 @@ const ControlCenterLayout: React.FC = () => {
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="gap-2 px-2">
|
||||
<Button variant="ghost" className="gap-2 px-2" data-testid="menu-user">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user?.avatar_url ?? undefined} alt={displayName} />
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
@@ -299,7 +307,7 @@ const ControlCenterLayout: React.FC = () => {
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuContent align="end" className="w-56" data-testid="menu-user-content">
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-col">
|
||||
<span>{displayName}</span>
|
||||
@@ -307,7 +315,7 @@ const ControlCenterLayout: React.FC = () => {
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate('/profile')}>
|
||||
<DropdownMenuItem data-testid="menu-profile" onClick={() => navigate('/profile')}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
{t('layout.myProfile')}
|
||||
</DropdownMenuItem>
|
||||
@@ -317,6 +325,7 @@ const ControlCenterLayout: React.FC = () => {
|
||||
{t('layout.uiLanguage')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
data-testid="lang-ru"
|
||||
disabled={updateProfile.isPending}
|
||||
onClick={() => {
|
||||
if ((user?.language || 'ru') === 'ru') return;
|
||||
@@ -332,6 +341,7 @@ const ControlCenterLayout: React.FC = () => {
|
||||
{t('common.langRu')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="lang-en"
|
||||
disabled={updateProfile.isPending}
|
||||
onClick={() => {
|
||||
if (user?.language === 'en') return;
|
||||
@@ -356,7 +366,11 @@ const ControlCenterLayout: React.FC = () => {
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
|
||||
<DropdownMenuItem
|
||||
data-testid="btn-logout"
|
||||
onClick={handleLogout}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t('layout.logout')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Build stable data-testid values for E2E. */
|
||||
export function testid(parts: Array<string | number | undefined | null>): string {
|
||||
return parts
|
||||
.filter((p) => p !== undefined && p !== null && `${p}`.length > 0)
|
||||
.map((p) => String(p).replace(/\s+/g, '-'))
|
||||
.join('-');
|
||||
}
|
||||
@@ -44,7 +44,7 @@ const LoginPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden p-4">
|
||||
<div data-testid="page-login" className="relative flex min-h-screen items-center justify-center overflow-hidden p-4">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_hsl(221_83%_53%_/_0.18),_transparent_55%),linear-gradient(160deg,_hsl(220_20%_97%)_0%,_hsl(210_40%_96%)_45%,_hsl(221_40%_92%)_100%)]"
|
||||
@@ -93,7 +93,7 @@ const LoginPage: React.FC = () => {
|
||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
<Button type="submit" data-testid="btn-login" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? '…' : t('login.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -66,7 +66,7 @@ const DashboardPage: React.FC = () => {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div data-testid="page-dashboard" className="space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
@@ -90,7 +90,7 @@ const DashboardPage: React.FC = () => {
|
||||
|
||||
if (role === 'moderator') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div data-testid="page-dashboard" className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('dashboard.moderation')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('dashboard.subtitleModeration')}</p>
|
||||
@@ -121,7 +121,7 @@ const DashboardPage: React.FC = () => {
|
||||
|
||||
if (role === 'support') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div data-testid="page-dashboard" className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('dashboard.support')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('dashboard.subtitleSupport')}</p>
|
||||
@@ -155,7 +155,7 @@ const DashboardPage: React.FC = () => {
|
||||
const eventsSparkline = data.events_by_day?.map((item) => ({ date: item.date, value: item.count })) ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div data-testid="page-dashboard" className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('dashboard.controlCenter')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('dashboard.subtitleAdmin')}</p>
|
||||
|
||||
@@ -14,7 +14,7 @@ const ForbiddenPage: React.FC = () => {
|
||||
const sectionLabel = sectionItem ? t(sectionItem.label) : t('common.notFound');
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center">
|
||||
<div data-testid="page-forbidden" className="flex min-h-[50vh] items-center justify-center">
|
||||
<Card className="max-w-md w-full text-center">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-4xl font-bold">{t('errors.forbiddenTitle')}</CardTitle>
|
||||
@@ -23,7 +23,9 @@ const ForbiddenPage: React.FC = () => {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => navigate('/dashboard')}>{t('common.toDashboard')}</Button>
|
||||
<Button data-testid="btn-to-dashboard" onClick={() => navigate('/dashboard')}>
|
||||
{t('common.toDashboard')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user