import { afterEach, describe, expect, it, vi } from 'vitest' import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import uiPlugin from '@nuxt/ui/vue-plugin' import router from '@/router' import { useApi } from '@/composables/useApi' import { useColorMode } from '@/composables/useColorMode' import DashboardShell from '../DashboardShell.vue' import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn(), } }) interface ShellOptions { username?: string displayName?: string email?: string width?: number } let mountedShell: VueWrapper | null = null async function mountShell(path: string, options: ShellOptions = {}) { const { username = 'marta', displayName = '', email = 'marta@renew.example', width = DESKTOP_WIDTH, } = options localStorage.clear() setViewportWidth(width) setActivePinia(createPinia()) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn().mockResolvedValue({ verified: true, username, email, displayName }), request: vi.fn().mockResolvedValue(undefined), }) await router.push(path) const wrapper = mount(DashboardShell, { attachTo: document.body, global: { plugins: [router, uiPlugin], }, }) mountedShell = wrapper await flushPromises() return wrapper } afterEach(async () => { mountedShell?.unmount() mountedShell = null await router.push('/login') document.body.innerHTML = '' }) describe('DashboardShell renders the four navigation destinations', () => { it('lists Home, Articles, Users and Integration in order, with only Articles carrying aria-current', async () => { const wrapper = await mountShell('/article') const hrefs = wrapper .findAll('nav[aria-label="Main"] a') .map((link) => link.attributes('href')) expect(hrefs).toEqual(['/home', '/article', '/users', '/integration']) const home = wrapper.find('a[href="/home"]') const articles = wrapper.find('a[href="/article"]') const users = wrapper.find('a[href="/users"]') const integration = wrapper.find('a[href="/integration"]') expect(articles.attributes('aria-current')).toBe('page') expect(home.attributes('aria-current')).toBeUndefined() expect(users.attributes('aria-current')).toBeUndefined() expect(integration.attributes('aria-current')).toBeUndefined() }) it('marks Users with aria-current on /users', async () => { const wrapper = await mountShell('/users') const users = wrapper.find('a[href="/users"]') expect(users.text()).toContain('Users') expect(users.attributes('aria-current')).toBe('page') expect(wrapper.find('a[href="/article"]').attributes('aria-current')).toBeUndefined() }) }) describe('DashboardShell shows the offline banner when the browser reports offline', () => { it('shows the banner while offline and hides it once back online', async () => { const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false) const wrapper = await mountShell('/article') expect(wrapper.text()).toContain("You're offline") onLineSpy.mockReturnValue(true) window.dispatchEvent(new Event('online')) await flushPromises() expect(wrapper.text()).not.toContain("You're offline") onLineSpy.mockRestore() }) }) describe('The sidebar header renders AppLogo and the "RenewCMS" wordmark', () => { it('renders the logo before the wordmark text, neither as a link', async () => { const wrapper = await mountShell('/home') const header = wrapper.find('[data-slot="header"]') expect(header.find('svg[role="img"][aria-label="RenewCMS"]').exists()).toBe(true) expect(header.text()).toContain('RenewCMS') expect(header.find('a').exists()).toBe(false) const headerHtml = header.html() expect(headerHtml.indexOf('')) }) }) describe('The sidebar collapse trigger toggles the rail and exposes aria-expanded', () => { it('flips the rail and aria-expanded on repeated activation', async () => { await mountShell('/home') const collapseButton = () => document.body.querySelector('[data-testid="sidebar-collapse"]') as HTMLButtonElement expect(collapseButton().getAttribute('aria-expanded')).toBe('true') collapseButton().click() await flushPromises() expect(collapseButton().getAttribute('aria-expanded')).toBe('false') collapseButton().click() await flushPromises() expect(collapseButton().getAttribute('aria-expanded')).toBe('true') }) }) describe('A successful logout clears the session and navigates to /login', () => { it('calls POST /v1/auth/logout, clears the session and navigates to /login', async () => { await mountShell('/home') const logOutItem = () => Array.from(document.body.querySelectorAll('[role="menuitem"]')).find( (el) => el.textContent?.trim() === 'Log out', ) as HTMLElement const trigger = document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement trigger.click() await flushPromises() logOutItem().click() await flushPromises() expect(vi.mocked(useApi)().request).toHaveBeenCalledWith( '/v1/auth/logout', expect.objectContaining({ method: 'POST' }), ) expect(router.currentRoute.value.path).toBe('/login') }) }) describe('Collapsed sidebar hides the wordmark and username text but keeps an accessible name', () => { it('hides visible text but keeps accessible names for the logo and the user menu', async () => { await mountShell('/home') const collapseButton = document.body.querySelector( '[data-testid="sidebar-collapse"]', ) as HTMLButtonElement collapseButton.click() await flushPromises() const header = document.body.querySelector('[data-slot="header"]') as HTMLElement expect(header.querySelector('svg[role="img"][aria-label="RenewCMS"]')).toBeTruthy() expect(header.textContent).not.toContain('RenewCMS') expect(header.querySelector('span[aria-hidden="true"]')).toBeNull() const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement expect(footer.textContent).not.toContain('marta') const userMenuTrigger = footer.querySelector('button[aria-haspopup]') as HTMLButtonElement expect(userMenuTrigger.getAttribute('aria-label')).toBe('marta — account menu') expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA') }) }) describe('The account chip shows the display name and no item is current on /profile', () => { it('shows the display name and its initials in the sidebar chip', async () => { await mountShell('/home', { displayName: 'Marta Alves' }) const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement expect(footer.textContent).toContain('Marta Alves') expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA') }) it('keeps the collapsed accessible name on the display name', async () => { await mountShell('/home', { displayName: 'Marta Alves' }) const collapseButton = document.body.querySelector( '[data-testid="sidebar-collapse"]', ) as HTMLButtonElement collapseButton.click() await flushPromises() const trigger = document.body.querySelector('[data-slot="footer"] button[aria-haspopup]') expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu') }) it('marks none of the four navigation items current on /profile', async () => { const wrapper = await mountShell('/profile') const links = wrapper.findAll('nav[aria-label="Main"] a') expect(links).toHaveLength(4) links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined()) }) }) describe('Account menu: opened from the drawer on mobile', () => { it('closes the drawer and opens a bottom sheet with a grab handle, the header and 48 px items', async () => { await mountShell('/home', { width: MOBILE_WIDTH }) const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement toggle.click() await flushPromises() const accountRow = document.body.querySelector( '[role="dialog"] button[aria-haspopup]', ) as HTMLButtonElement expect(accountRow).not.toBeNull() accountRow.click() await flushPromises() const sheets = Array.from(document.body.querySelectorAll('[role="dialog"]')) expect(sheets).toHaveLength(1) const menu = document.body.querySelector('[role="menu"]') as HTMLElement expect( menu.querySelector('[aria-hidden="true"].w-9.h-1, [aria-hidden="true"].h-1'), ).not.toBeNull() expect(menu.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA') expect(menu.textContent).toContain('marta@renew.example') const labels = Array.from( menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'), ).map((item) => item.textContent?.trim()) expect(labels).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out']) menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]').forEach((item) => expect(item.className).toContain('h-12'), ) }) it('navigates to /profile and closes the sheet from Profile', async () => { await mountShell('/home', { width: MOBILE_WIDTH }) ;(document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement).click() await flushPromises() ;( document.body.querySelector( '[role="dialog"] button[aria-haspopup]', ) as HTMLButtonElement ).click() await flushPromises() const profile = Array.from( document.body.querySelectorAll('[role="menu"] [role="menuitem"]'), ).find((item) => item.textContent?.trim() === 'Profile')! profile.click() await flushPromises() expect(router.currentRoute.value.path).toBe('/profile') expect(document.body.querySelector('[role="menu"]')).toBeNull() }) it('applies a theme choice and closes the sheet', async () => { const colorMode = useColorMode() colorMode.preference = 'system' await mountShell('/home', { width: MOBILE_WIDTH }) ;(document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement).click() await flushPromises() ;( document.body.querySelector( '[role="dialog"] button[aria-haspopup]', ) as HTMLButtonElement ).click() await flushPromises() const dark = Array.from( document.body.querySelectorAll('[role="menu"] [role="menuitemcheckbox"]'), ).find((item) => item.textContent?.trim() === 'Dark')! dark.click() await flushPromises() expect(colorMode.preference).toBe('dark') expect(document.body.querySelector('[role="menu"]')).toBeNull() }) it('logs out from the sheet', async () => { await mountShell('/home', { width: MOBILE_WIDTH }) ;(document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement).click() await flushPromises() ;( document.body.querySelector( '[role="dialog"] button[aria-haspopup]', ) as HTMLButtonElement ).click() await flushPromises() const logOut = Array.from( document.body.querySelectorAll('[role="menu"] [role="menuitem"]'), ).find((item) => item.textContent?.trim() === 'Log out')! logOut.click() await flushPromises() expect(vi.mocked(useApi)().request).toHaveBeenCalledWith( '/v1/auth/logout', expect.objectContaining({ method: 'POST' }), ) expect(router.currentRoute.value.path).toBe('/login') }) })