344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
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<typeof import('@/composables/useApi')>()
|
|
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 five navigation destinations', () => {
|
|
it('lists Home, Articles, Users, Integration and Settings 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', '/settings'])
|
|
|
|
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('lists the same five destinations in the mobile drawer and Settings current', async () => {
|
|
await mountShell('/settings', { width: MOBILE_WIDTH })
|
|
|
|
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
|
|
toggle.click()
|
|
await flushPromises()
|
|
|
|
const links = Array.from(
|
|
document.body.querySelectorAll('[role="dialog"] nav[aria-label="Main"] a'),
|
|
)
|
|
expect(links.map((link) => link.getAttribute('href'))).toEqual([
|
|
'/home',
|
|
'/article',
|
|
'/users',
|
|
'/integration',
|
|
'/settings',
|
|
])
|
|
expect(links[4].querySelector('[class*="lucide"], svg')).not.toBeNull()
|
|
expect(links[4].getAttribute('aria-current')).toBe('page')
|
|
})
|
|
|
|
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('<svg')).toBeLessThan(headerHtml.indexOf('RenewCMS</span>'))
|
|
})
|
|
})
|
|
|
|
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 five navigation items current on /profile', async () => {
|
|
const wrapper = await mountShell('/profile')
|
|
|
|
const links = wrapper.findAll('nav[aria-label="Main"] a')
|
|
expect(links).toHaveLength(5)
|
|
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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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')
|
|
})
|
|
})
|