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 { readFileSync } from 'node:fs' import { join } from 'node:path' import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport' import type { Access } from '@/lib/permissions' import { accessFor, customAccess, writerAccess } from '@/test/access' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn(), } }) interface ShellOptions { username?: string displayName?: string email?: string role?: string access?: Access width?: number } let mountedShell: VueWrapper | null = null async function mountShell(path: string, options: ShellOptions = {}) { const { username = 'marta', displayName = '', email = 'marta@renew.example', role = 'noschema', access, width = DESKTOP_WIDTH, } = options localStorage.clear() setViewportWidth(width) setActivePinia(createPinia()) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn().mockResolvedValue({ verified: true, username, email, displayName, access: access ?? accessFor(role), }), 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 eight navigation destinations', () => { it('lists Home, Articles, Media, Authors, Users, Activity, 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', '/media', '/authors', '/users', '/activity', '/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 eight 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'), ).filter( (link) => link.getAttribute('href') === '/settings' || !link.getAttribute('href')?.startsWith('/settings/'), ) expect(links.map((link) => link.getAttribute('href'))).toEqual([ '/home', '/article', '/media', '/authors', '/users', '/activity', '/integration', '/settings', ]) expect(links[7].querySelector('[class*="lucide"], svg')).not.toBeNull() expect(links[7].getAttribute('aria-current')).toBe('page') }) it('marks Media with aria-current on /media, in the sidebar and in the mobile drawer', async () => { const wrapper = await mountShell('/media') const media = wrapper.find('a[href="/media"]') expect(media.text()).toContain('Media') expect(media.attributes('aria-current')).toBe('page') expect(media.find('svg, [class*="lucide"]').exists()).toBe(true) expect(wrapper.find('a[href="/article"]').attributes('aria-current')).toBeUndefined() expect(wrapper.find('a[href="/users"]').attributes('aria-current')).toBeUndefined() wrapper.unmount() mountedShell = null document.body.innerHTML = '' await mountShell('/media', { width: MOBILE_WIDTH }) const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement toggle.click() await flushPromises() const drawerMedia = document.body.querySelector( '[role="dialog"] nav[aria-label="Main"] a[href="/media"]', ) expect(drawerMedia?.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('')) }) }) 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 eight navigation items current on /profile', async () => { const wrapper = await mountShell('/profile') const links = wrapper.findAll('nav[aria-label="Main"] a') expect(links).toHaveLength(8) 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="menuitemradio"]'), ).map((item) => item.textContent?.trim()) expect(labels).toEqual([ 'Profile', 'System', 'Light', 'Dark', 'English', 'Français', 'Log out', ]) menu.querySelectorAll('[role="menuitem"], [role="menuitemradio"]').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 keeps the sheet open', 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="menuitemradio"]'), ).find((item) => item.textContent?.trim() === 'Dark')! dark.click() await flushPromises() expect(colorMode.preference).toBe('dark') expect(document.body.querySelector('[role="menu"]')).not.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') }) }) describe('DashboardShell Settings expands to the Media subpages', () => { it.each(['owner', 'admin'])( 'shows Media: Variants and Cache under Settings for %s on /settings', async (role) => { const wrapper = await mountShell('/settings', { role }) const nav = wrapper.find('nav[aria-label="Main"]') expect(nav.text()).toContain('Media') const variants = nav.find('a[href="/settings/media/variants"]') expect(variants.text()).toContain('Variants') expect(variants.attributes('aria-current')).toBeUndefined() const cache = nav.find('a[href="/settings/media/cache"]') expect(cache.text()).toContain('Cache') expect(cache.attributes('aria-current')).toBeUndefined() expect(nav.find('a[href="/settings"]').attributes('aria-current')).toBe('page') }, ) it('marks Variants current and Settings not current on the variants page', async () => { const wrapper = await mountShell('/settings/media/variants', { role: 'admin', }) const nav = wrapper.find('nav[aria-label="Main"]') expect(nav.find('a[href="/settings/media/variants"]').attributes('aria-current')).toBe( 'page', ) expect(nav.find('a[href="/settings"]').attributes('aria-current')).toBeUndefined() }) it('marks Cache current and Settings not current on the cache page', async () => { const wrapper = await mountShell('/settings/media/cache', { role: 'admin', }) const nav = wrapper.find('nav[aria-label="Main"]') expect(nav.find('a[href="/settings/media/cache"]').attributes('aria-current')).toBe('page') expect(nav.find('a[href="/settings"]').attributes('aria-current')).toBeUndefined() }) it('shows no Media subpages without an owner or admin role', async () => { const wrapper = await mountShell('/settings', { role: 'user' }) const nav = wrapper.find('nav[aria-label="Main"]') expect(nav.find('a[href="/settings/media/variants"]').exists()).toBe(false) expect(nav.text()).not.toContain('Cache') }) }) describe('DashboardShell selected nav rows use sand with on-sand text', () => { const SAND = 'bg-(--rc-sand)' const ON_SAND = 'text-(--rc-on-sand)' const STATES = ['', 'hover:', 'active:', 'focus-visible:'] const selectedClasses = STATES.flatMap((state) => [`${state}${SAND}`, `${state}${ON_SAND}`]) const SCOPED_BOLD = '[li[data-state=open]>&:not([aria-current=page])]:font-medium' const UNSCOPED_BOLD = '[li[data-state=open]>&]:font-medium' it.each([ '/settings', '/settings/media/variants', '/settings/media/cache', '/home', '/users', '/activity', ])('fills the selected row on %s with sand and on-sand in every state', async (path) => { const wrapper = await mountShell(path, { role: 'owner' }) const nav = wrapper.find('nav[aria-label="Main"]') const selected = nav.find(`a[href="${path}"]`) expect(selected.attributes('aria-current')).toBe('page') expect(selected.classes()).toEqual(expect.arrayContaining(selectedClasses)) expect(selected.classes()).toContain('focus-visible:outline-(--ui-primary)') expect(selected.find('[class*="--rc-on-sand"]').exists()).toBe(true) const unselected = nav .findAll('a') .filter((link) => link.attributes('aria-current') === undefined) expect(unselected.length).toBeGreaterThan(0) for (const link of unselected) { for (const cls of selectedClasses) { expect(link.classes()).not.toContain(cls) } } }) it('keeps the unfilled Settings parent bold when a child is selected', async () => { const wrapper = await mountShell('/settings/media/variants', { role: 'owner', }) const parent = wrapper.find('nav[aria-label="Main"] a[href="/settings"]') expect(parent.attributes('aria-current')).toBeUndefined() expect(parent.classes()).not.toContain(SAND) expect(parent.classes()).toContain(SCOPED_BOLD) expect(parent.classes()).not.toContain(UNSCOPED_BOLD) }) it('scopes the open-parent bold rule away from the selected row', async () => { const wrapper = await mountShell('/settings', { role: 'owner' }) const selected = wrapper.find('nav[aria-label="Main"] a[href="/settings"]') expect(selected.classes()).toContain(SCOPED_BOLD) expect(selected.classes()).not.toContain(UNSCOPED_BOLD) expect(selected.classes()).not.toContain('[li[data-state=open]>&]:text-highlighted') }) it('draws drawer rows 44 px tall with the same selected treatment', async () => { await mountShell('/settings', { role: 'owner', width: MOBILE_WIDTH }) const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement toggle.click() await flushPromises() const settings = document.body.querySelector( '[role="dialog"] nav[aria-label="Main"] a[href="/settings"]', ) as HTMLElement expect(settings.classList.contains('max-lg:h-11')).toBe(true) for (const cls of selectedClasses) { expect(settings.classList.contains(cls)).toBe(true) } }) }) const mainCss = readFileSync(join(import.meta.dirname, '../../assets/main.css'), 'utf8') function declarationsOf(selector: string) { const start = mainCss.indexOf(`\n${selector} {`) const end = mainCss.indexOf('\n}', start) const block = mainCss.slice(start, end) return new Map( [...block.matchAll(/^\t(--[a-z0-9-]+): ([^;]+);$/gm)].map((match) => [ match[1]!, match[2]!, ]), ) } function resolveToken(declarations: Map, token: string) { const value = declarations.get(token) ?? declarationsOf(':root').get(token)! const alias = /^var\((--[a-z0-9-]+)\)$/.exec(value) if (!alias) { return value } return declarations.get(alias[1]!) ?? resolveFromTheme(alias[1]!) } function resolveFromTheme(token: string) { return new RegExp(`^\t${token}: ([^;]+);$`, 'm').exec(mainCss)![1]! } const tokenRows: [string, 'light' | 'dark', string][] = [ ['--rc-sand', 'light', '#e8dfd1'], ['--rc-sand', 'dark', '#d4c5b0'], ['--rc-on-sand', 'light', '#111111'], ['--rc-on-sand', 'dark', '#111111'], ['--rc-success-soft', 'light', '#eaefea'], ['--rc-success-soft', 'dark', '#1f241f'], ['--rc-info-soft', 'light', '#ebeef0'], ['--rc-info-soft', 'dark', '#202428'], ['--rc-warning-soft', 'light', '#f0ede6'], ['--rc-warning-soft', 'dark', '#2a251e'], ['--rc-error-soft', 'light', '#f3eae9'], ['--rc-error-soft', 'dark', '#2c201f'], ['--rc-overlay', 'light', 'rgb(10 10 10 / 0.4)'], ['--rc-overlay', 'dark', 'rgb(0 0 0 / 0.6)'], ['--rc-shadow-pop', 'light', '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)'], ['--rc-shadow-pop', 'dark', '0 4px 16px rgb(0 0 0 / 0.5)'], ['--rc-shadow-modal', 'light', '0 16px 48px rgb(17 17 17 / 0.18)'], ['--rc-shadow-modal', 'dark', '0 16px 48px rgb(0 0 0 / 0.6)'], ] const lightUiTokens = { '--ui-bg': 'var(--color-white)', '--ui-bg-muted': 'var(--color-chalk-50)', '--ui-bg-elevated': 'var(--color-chalk-100)', '--ui-bg-accented': 'var(--color-chalk-200)', '--ui-bg-inverted': 'var(--color-chalk-900)', '--ui-text-dimmed': 'var(--color-chalk-400)', '--ui-text-muted': 'var(--color-chalk-500)', '--ui-text-toned': 'var(--color-chalk-600)', '--ui-text': 'var(--color-chalk-800)', '--ui-text-highlighted': 'var(--color-chalk-900)', '--ui-text-inverted': 'var(--color-white)', '--ui-border': 'var(--color-chalk-200)', '--ui-border-muted': 'var(--color-chalk-100)', '--ui-border-accented': 'var(--color-chalk-450)', '--ui-border-inverted': 'var(--color-chalk-900)', '--ui-primary': 'var(--ui-color-primary-900)', '--ui-secondary': 'var(--ui-color-secondary-700)', '--ui-success': 'var(--ui-color-success-700)', '--ui-info': 'var(--ui-color-info-700)', '--ui-warning': 'var(--ui-color-warning-700)', '--ui-error': 'var(--ui-color-error-700)', } const darkUiTokens = { '--ui-bg': 'var(--color-chalk-900)', '--ui-bg-muted': 'var(--color-chalk-950)', '--ui-bg-elevated': 'var(--color-chalk-850)', '--ui-bg-accented': 'var(--color-chalk-800)', '--ui-bg-inverted': 'var(--color-chalk-50)', '--ui-text-dimmed': 'var(--color-chalk-500)', '--ui-text-muted': 'var(--color-chalk-400)', '--ui-text-toned': 'var(--color-chalk-300)', '--ui-text': 'var(--color-chalk-200)', '--ui-text-highlighted': 'var(--color-chalk-50)', '--ui-text-inverted': 'var(--color-chalk-900)', '--ui-border': 'var(--color-chalk-700)', '--ui-border-muted': 'var(--color-chalk-800)', '--ui-border-accented': 'var(--color-chalk-500)', '--ui-border-inverted': 'var(--color-chalk-50)', '--ui-primary': 'var(--ui-color-primary-50)', '--ui-secondary': 'var(--ui-color-secondary-300)', '--ui-success': 'var(--ui-color-success-300)', '--ui-info': 'var(--ui-color-info-300)', '--ui-warning': 'var(--ui-color-warning-300)', '--ui-error': 'var(--ui-color-error-300)', } function uiTokensOf(declarations: Map) { return Object.fromEntries( [...declarations].filter( ([name]) => name.startsWith('--ui-') && !/radius|header|container/.test(name), ), ) } describe('main.css declares the shell design tokens', () => { it.each(tokenRows)( '%s resolves to the foundations value in the %s theme', (token, theme, value) => { const declarations = declarationsOf(theme === 'light' ? ':root' : '.dark') expect(resolveToken(declarations, token)).toBe(value) }, ) it('declares no --rc-canvas', () => { expect(mainCss).not.toContain('--rc-canvas') }) it('leaves every --ui-* token value unchanged', () => { expect(uiTokensOf(declarationsOf(':root'))).toEqual(lightUiTokens) expect(uiTokensOf(declarationsOf('.dark'))).toEqual(darkUiTokens) }) }) const SELECTED_BASE = ['bg-(--rc-sand)', 'text-(--rc-on-sand)'] const SELECTED_STATES = ['hover', 'active', 'focus-visible'].flatMap((state) => [ `${state}:bg-(--rc-sand)`, `${state}:text-(--rc-on-sand)`, ]) const FORBIDDEN_TEXT = /(^|:)(text-muted|text-white|text-inverted)$/ function navLink(root: ParentNode, href: string) { return root.querySelector(`nav[aria-label="Main"] a[href="${href}"]`) as HTMLElement } function classesOf(element: Element) { return Array.from(element.classList) } function expectSelected(link: HTMLElement) { const classes = classesOf(link) for (const cls of [...SELECTED_BASE, ...SELECTED_STATES]) { expect(classes).toContain(cls) } expect(link.getAttribute('aria-current')).toBe('page') const forbidden = classes.filter((cls) => FORBIDDEN_TEXT.test(cls)) expect(forbidden).toEqual([]) const iconClasses = classesOf(link.querySelector('svg, [data-slot="linkLeadingIcon"]')!) expect(iconClasses.filter((cls) => FORBIDDEN_TEXT.test(cls))).toEqual([]) } function expectUnfilled(link: HTMLElement) { expect(link.getAttribute('aria-current')).toBeNull() expect(classesOf(link).filter((cls) => cls.includes('--rc-sand'))).toEqual([]) } function expectBoldParent(link: HTMLElement) { expectUnfilled(link) const classes = classesOf(link) expect(classes).toContain('[li[data-state=open]>&:not([aria-current=page])]:text-highlighted') expect(classes).toContain('[li[data-state=open]>&:not([aria-current=page])]:font-medium') expect(link.closest('li')!.getAttribute('data-state')).toBe('open') const chevron = link.querySelector('[data-slot="linkTrailingIcon"]')! expect(classesOf(chevron)).toContain('group-data-[state=open]:rotate-90') } describe('DashboardShell selected nav rows keep the sand contract in every state', () => { it('gives the selected Settings row a 2 px --ui-primary focus outline and keeps the sand fill', async () => { const wrapper = await mountShell('/settings', { role: 'owner' }) const classes = classesOf(navLink(wrapper.element, '/settings')) expect(classes).toContain('focus-visible:outline-2') expect(classes).toContain('focus-visible:outline-(--ui-primary)') expect(classes).toContain('focus-visible:bg-(--rc-sand)') expect(classes).toContain('bg-(--rc-sand)') }) it.each([ ['/settings', '/settings'], ['/settings/media/variants', '/settings/media/variants'], ['/settings/media/cache', '/settings/media/cache'], ['/home', '/home'], ['/users', '/users'], ['/activity', '/activity'], ])( 'paints the selected row on %s with sand and never white or muted text', async (path, href) => { const wrapper = await mountShell(path, { role: 'owner' }) expectSelected(navLink(wrapper.element, href)) }, ) it('keeps Variants and Cache unselected under the filled Settings row on /settings', async () => { const wrapper = await mountShell('/settings', { role: 'owner' }) expectUnfilled(navLink(wrapper.element, '/settings/media/variants')) expectUnfilled(navLink(wrapper.element, '/settings/media/cache')) }) it.each([ ['/settings/media/variants', '/settings/media/cache'], ['/settings/media/cache', '/settings/media/variants'], ])('shows Settings unfilled, bold and open when %s is selected', async (path, other) => { const wrapper = await mountShell(path, { role: 'owner' }) expectBoldParent(navLink(wrapper.element, '/settings')) expectUnfilled(navLink(wrapper.element, other)) }) it.each([ ['/settings', 'N1'], ['/settings/media/variants', 'N2'], ])('applies the same contract in the dark theme on %s (%s)', async (path) => { document.documentElement.classList.add('dark') try { const wrapper = await mountShell(path, { role: 'owner' }) if (path === '/settings') { expectSelected(navLink(wrapper.element, '/settings')) expectUnfilled(navLink(wrapper.element, '/settings/media/variants')) expectUnfilled(navLink(wrapper.element, '/settings/media/cache')) } else { expectSelected(navLink(wrapper.element, path)) expectBoldParent(navLink(wrapper.element, '/settings')) } } finally { document.documentElement.classList.remove('dark') } }) }) describe('DashboardShell mobile drawer rows', () => { async function openDrawer(path: string) { await mountShell(path, { role: 'owner', width: MOBILE_WIDTH }) const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement toggle.click() await flushPromises() return document.body.querySelector('[role="dialog"]') as HTMLElement } it('draws every drawer row 44 px tall', async () => { const drawer = await openDrawer('/settings') const links = Array.from(drawer.querySelectorAll('nav[aria-label="Main"] a')) expect(links).toHaveLength(12) for (const link of links) { expect(classesOf(link)).toContain('max-lg:h-11') } }) it('lists Variants and Cache unselected under the filled Settings row on /settings', async () => { const drawer = await openDrawer('/settings') expect(drawer.querySelector('nav[aria-label="Main"]')!.textContent).toContain('Media') expectSelected(navLink(drawer, '/settings')) expectUnfilled(navLink(drawer, '/settings/media/variants')) expectUnfilled(navLink(drawer, '/settings/media/cache')) }) it('fills Variants and leaves Settings unfilled and bold on /settings/media/variants', async () => { const drawer = await openDrawer('/settings/media/variants') expectSelected(navLink(drawer, '/settings/media/variants')) expectBoldParent(navLink(drawer, '/settings')) expectUnfilled(navLink(drawer, '/settings/media/cache')) }) }) function navHrefs(wrapper: VueWrapper) { return wrapper.findAll('nav[aria-label="Main"] a').map((link) => link.attributes('href')) } describe('DashboardShell lists items per permission', () => { it('shows Home, Articles, Media, Authors, Activity and Integration for a role with those grants only', async () => { const wrapper = await mountShell('/home', { access: customAccess( 'content.read, media.view, authors.view, activity.view=own', 'Contributor', ), }) expect(navHrefs(wrapper)).toEqual([ '/home', '/article', '/media', '/authors', '/activity', '/integration', ]) expect(wrapper.find('nav[aria-label="Main"]').text()).not.toContain('Roles') }) it('shows the same items in the mobile drawer', async () => { await mountShell('/home', { width: MOBILE_WIDTH, access: customAccess('content.read, media.view, authors.view, activity.view=own'), }) 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', '/media', '/authors', '/activity', '/integration', ]) }) it('shows the Writer built-in menu with Schema and no Users or Settings', async () => { const wrapper = await mountShell('/home', { access: writerAccess() }) expect(navHrefs(wrapper)).toEqual([ '/home', '/article', '/media', '/authors', '/schema', '/activity', '/integration', ]) }) it('shows the Sites child under Settings with settings.manage_sites', async () => { const wrapper = await mountShell('/settings', { access: customAccess('settings.view, settings.manage_sites'), }) expect(wrapper.find('a[href="/settings/sites"]').exists()).toBe(true) }) it('hides the Sites child for the built-in Admin without settings.manage_sites', async () => { const wrapper = await mountShell('/settings', { access: customAccess('settings.view'), }) expect(wrapper.find('a[href="/settings/sites"]').exists()).toBe(false) }) it('lists Sites for the built-in Admin', async () => { const wrapper = await mountShell('/settings', { role: 'admin' }) expect(wrapper.find('a[href="/settings/sites"]').exists()).toBe(true) }) })