feat(admin): Playwright E2E каркас — TESTIDS, login/shell/roles, CI mock. Refs EventHub/EventHubFrontAdmin#33 #34 #35
CI / test (push) Failing after 14m2s
CI / push-image (push) Has been skipped
CI / ci-done (push) Failing after 0s

This commit is contained in:
2026-07-14 22:18:34 +03:00
parent 2a32465050
commit 87c6b55e81
19 changed files with 493 additions and 1462 deletions
+6 -1
View File
@@ -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)}
+19 -5
View File
@@ -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>
+7
View File
@@ -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('-');
}
+2 -2
View File
@@ -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>
+4 -4
View File
@@ -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>
+4 -2
View File
@@ -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>