fix(e2e): restore theme toggles and role switcher locators across viewports
This commit is contained in:
parent
63b12b92ec
commit
30722da89e
4 changed files with 106 additions and 24 deletions
|
|
@ -2,12 +2,31 @@ import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
test.describe('Scenario 02: Theme Engine & UI Persistence', () => {
|
test.describe('Scenario 02: Theme Engine & UI Persistence', () => {
|
||||||
test('switches themes via real UI clicks and persists across page reloads', async ({ page }) => {
|
test('switches themes via real UI clicks and persists across page reloads', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForLoadState('domcontentloaded');
|
|
||||||
|
|
||||||
const darkToggle = page.locator('[data-testid="theme-toggle-dark"]');
|
const isMobile = await page.locator('[data-testid="mobile-menu-btn"]').isVisible();
|
||||||
const lightToggle = page.locator('[data-testid="theme-toggle-light"]');
|
|
||||||
const softToggle = page.locator('[data-testid="theme-toggle-soft"]');
|
const openThemeControls = async () => {
|
||||||
|
if (isMobile) {
|
||||||
|
const drawer = page.locator('[data-testid="mobile-menu-drawer"]');
|
||||||
|
if (!(await drawer.isVisible())) {
|
||||||
|
await page.locator('[data-testid="mobile-menu-btn"]').click();
|
||||||
|
await expect(drawer).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await openThemeControls();
|
||||||
|
|
||||||
|
const darkToggle = isMobile
|
||||||
|
? page.locator('[data-testid="mobile-menu-drawer"]').locator('[data-testid="theme-toggle-dark"]')
|
||||||
|
: page.locator('[data-testid="theme-toggle-dark"]');
|
||||||
|
const lightToggle = isMobile
|
||||||
|
? page.locator('[data-testid="mobile-menu-drawer"]').locator('[data-testid="theme-toggle-light"]')
|
||||||
|
: page.locator('[data-testid="theme-toggle-light"]');
|
||||||
|
const softToggle = isMobile
|
||||||
|
? page.locator('[data-testid="mobile-menu-drawer"]').locator('[data-testid="theme-toggle-soft"]')
|
||||||
|
: page.locator('[data-testid="theme-toggle-soft"]');
|
||||||
|
|
||||||
await expect(darkToggle).toBeVisible({ timeout: 15000 });
|
await expect(darkToggle).toBeVisible({ timeout: 15000 });
|
||||||
await expect(lightToggle).toBeVisible();
|
await expect(lightToggle).toBeVisible();
|
||||||
|
|
@ -22,6 +41,8 @@ test.describe('Scenario 02: Theme Engine & UI Persistence', () => {
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('domcontentloaded');
|
||||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||||
|
|
||||||
|
await openThemeControls();
|
||||||
|
|
||||||
// 3. Switch to Light Theme
|
// 3. Switch to Light Theme
|
||||||
await lightToggle.click();
|
await lightToggle.click();
|
||||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,29 @@ import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
test.describe('Scenario 09: RBAC Guard & 403 Forbidden Handling', () => {
|
test.describe('Scenario 09: RBAC Guard & 403 Forbidden Handling', () => {
|
||||||
test('denies restricted role access to protected module and restores access upon switching back to Owner', async ({ page }) => {
|
test('denies restricted role access to protected module and restores access upon switching back to Owner', async ({ page }) => {
|
||||||
await page.goto('/settings');
|
await page.goto('/settings', { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForLoadState('domcontentloaded');
|
|
||||||
|
|
||||||
// 1. Switch to restricted role: Cashier via Settings or Topbar
|
// 1. Switch to restricted role: Cashier via Settings, Topbar or Mobile Menu
|
||||||
const cashierRoleBtn = page.locator('[data-testid="settings-role-cashier"]');
|
const cashierRoleBtn = page.locator('[data-testid="settings-role-cashier"]');
|
||||||
if (await cashierRoleBtn.isVisible()) {
|
const mobileMenuBtn = page.locator('[data-testid="mobile-menu-btn"]');
|
||||||
|
const roleSwitcher = page.locator('[data-testid="role-switcher-btn"]');
|
||||||
|
|
||||||
|
if (await cashierRoleBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||||
await cashierRoleBtn.click();
|
await cashierRoleBtn.click();
|
||||||
|
} else if (await mobileMenuBtn.isVisible()) {
|
||||||
|
await mobileMenuBtn.click();
|
||||||
|
const roleOption = page.locator('[data-testid="mobile-menu-drawer"]').locator('[data-testid="role-option-cashier"]');
|
||||||
|
await expect(roleOption).toBeVisible({ timeout: 5000 });
|
||||||
|
await roleOption.click();
|
||||||
|
const closeBtn = page.locator('[data-testid="close-mobile-menu"]');
|
||||||
|
if (await closeBtn.isVisible()) {
|
||||||
|
await closeBtn.click();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const roleSwitcher = page.locator('[data-testid="role-switcher-btn"]');
|
await expect(roleSwitcher).toBeVisible({ timeout: 10000 });
|
||||||
await roleSwitcher.click();
|
await roleSwitcher.click();
|
||||||
const roleOption = page.locator('[data-testid="role-option-cashier"]');
|
const roleOption = page.locator('header').locator('[data-testid="role-option-cashier"]');
|
||||||
|
await expect(roleOption).toBeVisible({ timeout: 5000 });
|
||||||
await roleOption.click();
|
await roleOption.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,7 @@ export const MobileMenuDrawer: React.FC<MobileMenuDrawerProps> = ({ isOpen, onCl
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={rId}
|
key={rId}
|
||||||
|
data-testid={`role-option-${rId}`}
|
||||||
onClick={() => setRoleId(rId)}
|
onClick={() => setRoleId(rId)}
|
||||||
className={`min-h-[44px] p-2.5 rounded-xl text-left border flex flex-col justify-center transition-colors ${
|
className={`min-h-[44px] p-2.5 rounded-xl text-left border flex flex-col justify-center transition-colors ${
|
||||||
currentRoleId === rId
|
currentRoleId === rId
|
||||||
|
|
@ -197,14 +198,15 @@ export const MobileMenuDrawer: React.FC<MobileMenuDrawerProps> = ({ isOpen, onCl
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{[
|
{[
|
||||||
{ id: 'soft-light', label: 'Мягкая', icon: Sparkles },
|
{ id: 'soft-light', key: 'soft', label: 'Мягкая', icon: Sparkles },
|
||||||
{ id: 'light', label: 'Светлая', icon: Sun },
|
{ id: 'light', key: 'light', label: 'Светлая', icon: Sun },
|
||||||
{ id: 'dark', label: 'Тёмная', icon: Moon },
|
{ id: 'dark', key: 'dark', label: 'Тёмная', icon: Moon },
|
||||||
].map((th) => {
|
].map((th) => {
|
||||||
const Icon = th.icon;
|
const Icon = th.icon;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={th.id}
|
key={th.id}
|
||||||
|
data-testid={`theme-toggle-${th.key}`}
|
||||||
onClick={() => setTheme(th.id as any)}
|
onClick={() => setTheme(th.id as any)}
|
||||||
className={`min-h-[44px] px-2 py-2.5 rounded-xl border flex flex-col items-center justify-center gap-1 text-xs transition-colors ${
|
className={`min-h-[44px] px-2 py-2.5 rounded-xl border flex flex-col items-center justify-center gap-1 text-xs transition-colors ${
|
||||||
theme === th.id
|
theme === th.id
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
MapPin,
|
MapPin,
|
||||||
Shield,
|
Shield,
|
||||||
Menu,
|
Menu,
|
||||||
|
Palette,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { MobileMenuDrawer } from './MobileMenuDrawer';
|
import { MobileMenuDrawer } from './MobileMenuDrawer';
|
||||||
|
|
||||||
|
|
@ -37,6 +38,7 @@ export const Topbar: React.FC = () => {
|
||||||
} = useDemoStore();
|
} = useDemoStore();
|
||||||
|
|
||||||
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false);
|
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false);
|
||||||
|
const [isRoleDropdownOpen, setIsRoleDropdownOpen] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
|
@ -206,7 +208,12 @@ export const Topbar: React.FC = () => {
|
||||||
|
|
||||||
{/* Role Badge (RBAC) */}
|
{/* Role Badge (RBAC) */}
|
||||||
<div className="relative group hidden sm:block">
|
<div className="relative group hidden sm:block">
|
||||||
<div className="flex items-center gap-1.5 bg-krona-workspace border border-krona-border/80 rounded-xl px-2.5 py-1.5 cursor-pointer hover:border-krona-gold/50 transition-colors shadow-xs">
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="role-switcher-btn"
|
||||||
|
onClick={() => setIsRoleDropdownOpen(!isRoleDropdownOpen)}
|
||||||
|
className="flex items-center gap-1.5 bg-krona-workspace border border-krona-border/80 rounded-xl px-2.5 py-1.5 cursor-pointer hover:border-krona-gold/50 transition-colors shadow-xs text-left"
|
||||||
|
>
|
||||||
<Shield className="w-3.5 h-3.5 text-sky-500 shrink-0" />
|
<Shield className="w-3.5 h-3.5 text-sky-500 shrink-0" />
|
||||||
<div className="flex flex-col text-left">
|
<div className="flex flex-col text-left">
|
||||||
<span className="text-[9px] uppercase font-bold text-sky-500">
|
<span className="text-[9px] uppercase font-bold text-sky-500">
|
||||||
|
|
@ -217,9 +224,13 @@ export const Topbar: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown className="w-3 h-3 text-krona-secondary" />
|
<ChevronDown className="w-3 h-3 text-krona-secondary" />
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div className="absolute left-0 top-full mt-1 w-60 bg-krona-surface border border-krona-border rounded-xl shadow-krona-modal p-1.5 hidden group-hover:block z-50 animate-fade-in">
|
<div
|
||||||
|
className={`absolute left-0 top-full mt-1 w-60 bg-krona-surface border border-krona-border rounded-xl shadow-krona-modal p-1.5 z-50 animate-fade-in ${
|
||||||
|
isRoleDropdownOpen ? 'block' : 'hidden group-hover:block'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="px-2 py-1 text-[10px] font-bold text-krona-secondary uppercase tracking-wider border-b border-krona-border/60">
|
<div className="px-2 py-1 text-[10px] font-bold text-krona-secondary uppercase tracking-wider border-b border-krona-border/60">
|
||||||
Переключение роли
|
Переключение роли
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -229,7 +240,11 @@ export const Topbar: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={rId}
|
key={rId}
|
||||||
onClick={() => setRoleId(rId)}
|
data-testid={`role-option-${rId}`}
|
||||||
|
onClick={() => {
|
||||||
|
setRoleId(rId);
|
||||||
|
setIsRoleDropdownOpen(false);
|
||||||
|
}}
|
||||||
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-xs flex items-center justify-between transition-colors ${
|
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-xs flex items-center justify-between transition-colors ${
|
||||||
currentRoleId === rId
|
currentRoleId === rId
|
||||||
? 'bg-sky-500/15 text-krona-primary font-bold'
|
? 'bg-sky-500/15 text-krona-primary font-bold'
|
||||||
|
|
@ -279,14 +294,46 @@ export const Topbar: React.FC = () => {
|
||||||
<span className="hidden xl:inline">Krona AI</span>
|
<span className="hidden xl:inline">Krona AI</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Theme switcher */}
|
{/* Theme switcher (3 buttons) */}
|
||||||
<div className="flex items-center bg-krona-workspace p-0.5 rounded-xl border border-krona-border">
|
<div className="flex items-center bg-krona-workspace p-0.5 rounded-xl border border-krona-border gap-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setTheme(theme === 'dark' ? 'soft-light' : 'dark')}
|
data-testid="theme-toggle-soft"
|
||||||
className="p-1.5 rounded-lg text-krona-secondary hover:text-krona-primary transition-colors"
|
onClick={() => setTheme('soft-light')}
|
||||||
title={`Тема: ${theme === 'dark' ? 'Тёмная' : 'Светлая'}`}
|
className={`p-1.5 rounded-lg transition-colors ${
|
||||||
|
theme === 'soft-light'
|
||||||
|
? 'bg-krona-surface text-krona-gold font-bold shadow-xs'
|
||||||
|
: 'text-krona-secondary hover:text-krona-primary'
|
||||||
|
}`}
|
||||||
|
title="Тема: Мягкая (Soft)"
|
||||||
|
aria-label="Мягкая тема"
|
||||||
>
|
>
|
||||||
{theme === 'dark' ? <Moon className="w-3.5 h-3.5" /> : <Sun className="w-3.5 h-3.5" />}
|
<Palette className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid="theme-toggle-light"
|
||||||
|
onClick={() => setTheme('light')}
|
||||||
|
className={`p-1.5 rounded-lg transition-colors ${
|
||||||
|
theme === 'light'
|
||||||
|
? 'bg-krona-surface text-krona-gold font-bold shadow-xs'
|
||||||
|
: 'text-krona-secondary hover:text-krona-primary'
|
||||||
|
}`}
|
||||||
|
title="Тема: Светлая (Light)"
|
||||||
|
aria-label="Светлая тема"
|
||||||
|
>
|
||||||
|
<Sun className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid="theme-toggle-dark"
|
||||||
|
onClick={() => setTheme('dark')}
|
||||||
|
className={`p-1.5 rounded-lg transition-colors ${
|
||||||
|
theme === 'dark'
|
||||||
|
? 'bg-krona-surface text-krona-gold font-bold shadow-xs'
|
||||||
|
: 'text-krona-secondary hover:text-krona-primary'
|
||||||
|
}`}
|
||||||
|
title="Тема: Тёмная (Dark)"
|
||||||
|
aria-label="Тёмная тема"
|
||||||
|
>
|
||||||
|
<Moon className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue