test(10-05): bring every SPA module, composable and component under Vitest
- 41 unit and component suites under admin/tests/{app,state,shell,list,form,relation,views,ui}
covering states and a11y roles; every src module is imported by a test
- typed fixture helper assigns each JSON fixture to its generated OpenAPI type
- fix: iconFor ignores inherited object members such as "constructor"
- fix: field controls import ./control instead of the registry (import cycle
left a renderer unregistered depending on module load order)
- fix: dropdown shows the placeholder for an unknown stored value next to an emptyOption
- fix: list announces a failed schema load even when the rows arrive after it
- tailwind no longer scans admin/tests; boardwalk/dist rebuilt
This commit is contained in:
79
admin/tests/shell/AppShell.test.ts
Normal file
79
admin/tests/shell/AppShell.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import AppShell from '../../src/components/shell/AppShell.vue'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { NARROW_QUERY, setCollapsed } from '../../src/state/useSidebar'
|
||||
import { showToast } from '../../src/state/useToasts'
|
||||
import { navigationFixture } from '../fixtures/typed'
|
||||
import { fakeMedia, resetState, routerAt, signIn } from '../helpers'
|
||||
|
||||
async function mountShell(path: string) {
|
||||
await signIn()
|
||||
setNavigation(navigationFixture.data)
|
||||
const router = await routerAt(path)
|
||||
const wrapper = mount(AppShell, {
|
||||
slots: { default: '<p data-page>content</p>' },
|
||||
global: { plugins: [router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
setCollapsed(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setCollapsed(false)
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('app shell', () => {
|
||||
it('renders rail, section panel, header landmarks and the page in main', async () => {
|
||||
const wrapper = await mountShell('/acme/demo/widgets')
|
||||
expect(wrapper.find('nav[aria-label="Wtyczki"]').exists()).toBe(true)
|
||||
expect(wrapper.find('aside[aria-label="Sekcje"]').exists()).toBe(true)
|
||||
expect(wrapper.find('header nav[aria-label="Ścieżka nawigacji"]').exists()).toBe(true)
|
||||
expect(wrapper.find('header [data-user-menu]').exists()).toBe(true)
|
||||
expect(wrapper.find('main [data-page]').text()).toBe('content')
|
||||
})
|
||||
|
||||
it('has no section panel outside a permitted plugin', async () => {
|
||||
const wrapper = await mountShell('/settings')
|
||||
expect(wrapper.find('aside').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the section panel when collapsed by the admin or by the viewport', async () => {
|
||||
const wrapper = await mountShell('/acme/demo/widgets')
|
||||
setCollapsed(true)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('aside').exists()).toBe(false)
|
||||
setCollapsed(false)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('aside').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('follows the viewport while mounted and stops listening on unmount', async () => {
|
||||
const media = fakeMedia({ [NARROW_QUERY]: false })
|
||||
const wrapper = await mountShell('/acme/demo/widgets')
|
||||
expect(media.listeners(NARROW_QUERY)).toBe(1)
|
||||
media.change(NARROW_QUERY, true)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('aside').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
expect(media.listeners(NARROW_QUERY)).toBe(0)
|
||||
})
|
||||
|
||||
it('mounts the toast queue with status and alert roles', async () => {
|
||||
const wrapper = await mountShell('/acme/demo/widgets')
|
||||
showToast('Saved')
|
||||
showToast('Broken', 'danger')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="status"]').text()).toContain('Saved')
|
||||
expect(wrapper.find('[role="alert"]').text()).toContain('Broken')
|
||||
})
|
||||
})
|
||||
79
admin/tests/shell/Breadcrumbs.test.ts
Normal file
79
admin/tests/shell/Breadcrumbs.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import Breadcrumbs from '../../src/components/shell/Breadcrumbs.vue'
|
||||
import { setRecordCrumb } from '../../src/state/useBreadcrumbs'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import { navigationFixture, settingsFixture } from '../fixtures/typed'
|
||||
import { resetState, routerAt, signIn } from '../helpers'
|
||||
|
||||
async function crumbsAt(path: string) {
|
||||
await signIn()
|
||||
setNavigation(navigationFixture.data)
|
||||
setSettings(settingsFixture.list.data)
|
||||
const router = await routerAt(path)
|
||||
const wrapper = mount(Breadcrumbs, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[data-crumb]')
|
||||
return {
|
||||
wrapper,
|
||||
labels: items.map((item) => item.text()),
|
||||
links: items.map((item) => (item.find('a').exists() ? item.find('a').attributes('href') : null)),
|
||||
current: items.map((item) => item.find('[aria-current="page"]').exists()),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('breadcrumbs', () => {
|
||||
it('is a labelled nav listing plugin and controller, the last one current', async () => {
|
||||
const { wrapper, labels, links, current } = await crumbsAt('/acme/demo/gadgets')
|
||||
expect(wrapper.find('nav').attributes('aria-label')).toBe('Ścieżka nawigacji')
|
||||
expect(wrapper.find('ol').exists()).toBe(true)
|
||||
expect(labels).toEqual(['Demo', 'Gadgets'])
|
||||
expect(links).toEqual(['/acme/demo/widgets', null])
|
||||
expect(current).toEqual([false, true])
|
||||
expect(wrapper.findAll('svg[aria-hidden="true"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('adds the record title on record and create routes only', async () => {
|
||||
setRecordCrumb('Blue widget')
|
||||
let crumbs = await crumbsAt('/acme/demo/widgets/1')
|
||||
expect(crumbs.labels).toEqual(['Demo', 'Widgets', 'Blue widget'])
|
||||
expect(crumbs.links).toEqual(['/acme/demo/widgets', '/acme/demo/widgets', null])
|
||||
expect(crumbs.current).toEqual([false, false, true])
|
||||
|
||||
setRecordCrumb('New widget')
|
||||
crumbs = await crumbsAt('/acme/demo/widgets/create')
|
||||
expect(crumbs.labels).toEqual(['Demo', 'Widgets', 'New widget'])
|
||||
|
||||
crumbs = await crumbsAt('/acme/demo/widgets')
|
||||
expect(crumbs.labels).toEqual(['Demo', 'Widgets'])
|
||||
})
|
||||
|
||||
it('leaves out an empty record title', async () => {
|
||||
const { labels } = await crumbsAt('/acme/demo/widgets/1')
|
||||
expect(labels).toEqual(['Demo', 'Widgets'])
|
||||
})
|
||||
|
||||
it('shows Ustawienia and the page label on settings routes', async () => {
|
||||
expect((await crumbsAt('/settings')).labels).toEqual(['Ustawienia'])
|
||||
const page = await crumbsAt('/settings/mail')
|
||||
expect(page.labels).toEqual(['Ustawienia', 'Mail'])
|
||||
expect(page.links).toEqual(['/settings', null])
|
||||
expect((await crumbsAt('/settings/unknown')).labels).toEqual(['Ustawienia'])
|
||||
})
|
||||
|
||||
it('is empty outside any permitted plugin', async () => {
|
||||
expect((await crumbsAt('/acme/secret/things')).labels).toEqual([])
|
||||
expect((await crumbsAt('/acme/demo/unlisted')).labels).toEqual(['Demo'])
|
||||
})
|
||||
})
|
||||
136
admin/tests/shell/PluginRail.test.ts
Normal file
136
admin/tests/shell/PluginRail.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import PluginRail from '../../src/components/shell/PluginRail.vue'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import { NARROW_QUERY, SIDEBAR_KEY, setCollapsed, watchViewport } from '../../src/state/useSidebar'
|
||||
import { navigationFixture, settingsFixture } from '../fixtures/typed'
|
||||
import { fakeMedia, keydown, resetState, routerAt, signIn, wait } from '../helpers'
|
||||
|
||||
async function mountRail(path: string) {
|
||||
await signIn()
|
||||
setNavigation(navigationFixture.data)
|
||||
const router = await routerAt(path)
|
||||
const wrapper = mount(PluginRail, { global: { plugins: [router] }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
let stopViewport: (() => void) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
setCollapsed(false)
|
||||
stopViewport = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
stopViewport?.()
|
||||
setCollapsed(false)
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('plugin rail', () => {
|
||||
it('is a labelled navigation of the permitted plugins in order, the active one aria-current', async () => {
|
||||
const { wrapper } = await mountRail('/acme/demo/gadgets')
|
||||
const nav = wrapper.find('nav')
|
||||
expect(nav.attributes('aria-label')).toBe('Wtyczki')
|
||||
const items = wrapper.findAll('[data-rail-item]')
|
||||
expect(items.map((item) => item.attributes('data-rail-item'))).toEqual(['demo', 'tools'])
|
||||
expect(items.map((item) => item.text())).toEqual(['Demo', 'Tools'])
|
||||
expect(items[0]!.attributes('aria-current')).toBe('page')
|
||||
expect(items[1]!.attributes('aria-current')).toBeUndefined()
|
||||
expect(items[0]!.attributes('href')).toBe('/acme/demo/widgets')
|
||||
expect(items[1]!.attributes('href')).toBe('/acme/tools/hammers')
|
||||
// Expanded: no popup semantics on the items.
|
||||
expect(items[0]!.attributes('aria-haspopup')).toBeUndefined()
|
||||
expect(items[0]!.attributes('aria-expanded')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows Ustawienia only with a permitted settings page, current on settings routes', async () => {
|
||||
const { wrapper } = await mountRail('/settings/mail')
|
||||
expect(wrapper.find('[data-rail-settings]').exists()).toBe(false)
|
||||
setSettings(settingsFixture.list.data)
|
||||
await flushPromises()
|
||||
const link = wrapper.find('[data-rail-settings]')
|
||||
expect(link.text()).toBe('Ustawienia')
|
||||
expect(link.attributes('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('offers menu semantics and the expand button after a manual collapse', async () => {
|
||||
const { wrapper } = await mountRail('/acme/demo/widgets')
|
||||
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
|
||||
setCollapsed(true)
|
||||
await flushPromises()
|
||||
const item = wrapper.find('[data-rail-item="demo"]')
|
||||
expect(item.attributes('aria-haspopup')).toBe('menu')
|
||||
expect(item.attributes('aria-expanded')).toBe('false')
|
||||
const expand = wrapper.find('[data-sidebar-expand]')
|
||||
expect(expand.attributes('aria-label')).toBe('Rozwiń menu')
|
||||
|
||||
await expand.trigger('click')
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false')
|
||||
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('has no expand button when the viewport forces the collapse', async () => {
|
||||
fakeMedia({ [NARROW_QUERY]: true })
|
||||
stopViewport = watchViewport()
|
||||
const { wrapper } = await mountRail('/acme/demo/widgets')
|
||||
expect(wrapper.find('[data-rail-item="demo"]').attributes('aria-haspopup')).toBe('menu')
|
||||
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('opens the flyout on hover, links it by aria-controls and closes it on expand', async () => {
|
||||
const { wrapper } = await mountRail('/acme/demo/widgets')
|
||||
setCollapsed(true)
|
||||
await flushPromises()
|
||||
const item = wrapper.find('[data-rail-item="tools"]')
|
||||
await item.trigger('mouseenter')
|
||||
const flyout = document.querySelector('[data-section-flyout]')
|
||||
expect(flyout?.getAttribute('aria-label')).toBe('Tools')
|
||||
expect(item.attributes('aria-expanded')).toBe('true')
|
||||
expect(item.attributes('aria-controls')).toBe(flyout?.id)
|
||||
|
||||
setCollapsed(false)
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[data-section-flyout]')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores keys while expanded and closes an open flyout on Escape', async () => {
|
||||
const { wrapper } = await mountRail('/acme/demo/widgets')
|
||||
const item = wrapper.find('[data-rail-item="demo"]').element
|
||||
expect(keydown(item, 'Enter').defaultPrevented).toBe(false)
|
||||
|
||||
setCollapsed(true)
|
||||
await flushPromises()
|
||||
expect(keydown(item, 'ArrowDown').defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
await wait(0)
|
||||
expect(document.querySelector('[data-section-flyout]')).not.toBeNull()
|
||||
expect(keydown(item, 'Escape').defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[data-section-flyout]')).toBeNull()
|
||||
expect(keydown(item, 'Escape').defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the flyout open while focus stays on its rail item', async () => {
|
||||
const { wrapper } = await mountRail('/acme/demo/widgets')
|
||||
setCollapsed(true)
|
||||
await flushPromises()
|
||||
const item = wrapper.find('[data-rail-item="demo"]')
|
||||
;(item.element as HTMLElement).focus()
|
||||
await item.trigger('focus')
|
||||
expect(document.querySelector('[data-section-flyout]')).not.toBeNull()
|
||||
await item.trigger('mouseleave')
|
||||
await wait(250)
|
||||
expect(document.querySelector('[data-section-flyout]')).not.toBeNull()
|
||||
;(item.element as HTMLElement).blur()
|
||||
await item.trigger('blur')
|
||||
await wait(250)
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[data-section-flyout]')).toBeNull()
|
||||
})
|
||||
})
|
||||
88
admin/tests/shell/SectionFlyout.test.ts
Normal file
88
admin/tests/shell/SectionFlyout.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import SectionFlyout from '../../src/components/shell/SectionFlyout.vue'
|
||||
import type { NavigationEntry } from '../../src/api/types'
|
||||
import { clone, navigationFixture } from '../fixtures/typed'
|
||||
import { keydown, resetState, routerAt, signIn } from '../helpers'
|
||||
|
||||
function entryWithThree(): NavigationEntry {
|
||||
const entry = clone(navigationFixture.data[0]!)
|
||||
entry.sideMenu.push({ code: 'gizmos', label: 'Gizmos', icon: 'list', order: 0, controller: 'acme.demo.gizmos', sideMenu: [] })
|
||||
entry.sideMenu.push({ code: 'broken', label: 'Broken', icon: 'list', order: 0, controller: 'bad', sideMenu: [] })
|
||||
return entry
|
||||
}
|
||||
|
||||
async function mountFlyout(path = '/acme/demo/gadgets') {
|
||||
await signIn()
|
||||
const router = await routerAt(path)
|
||||
const wrapper = mount(SectionFlyout, {
|
||||
props: { entry: entryWithThree(), id: 'section-flyout-demo' },
|
||||
global: { plugins: [router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const active = () => document.activeElement?.textContent?.trim()
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('section flyout', () => {
|
||||
it('is a labelled menu of menuitems outside the tab order, the current one aria-current', async () => {
|
||||
const wrapper = await mountFlyout()
|
||||
const menu = wrapper.find('[role="menu"]')
|
||||
expect(menu.attributes('id')).toBe('section-flyout-demo')
|
||||
expect(menu.attributes('aria-label')).toBe('Demo')
|
||||
const items = wrapper.findAll('[role="menuitem"]')
|
||||
expect(items.map((item) => item.text())).toEqual(['Widgets', 'Gadgets', 'Gizmos'])
|
||||
expect(items.every((item) => item.attributes('tabindex') === '-1')).toBe(true)
|
||||
expect(items[1]!.attributes('aria-current')).toBe('page')
|
||||
expect(items[0]!.attributes('aria-current')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('focuses the first item and moves with arrows, Home and End, wrapping around', async () => {
|
||||
const wrapper = await mountFlyout()
|
||||
const menu = wrapper.find('[role="menu"]').element
|
||||
await (wrapper.vm as unknown as { focusFirst: () => Promise<void> }).focusFirst()
|
||||
expect(active()).toBe('Widgets')
|
||||
keydown(document.activeElement!, 'ArrowDown')
|
||||
expect(active()).toBe('Gadgets')
|
||||
keydown(document.activeElement!, 'End')
|
||||
expect(active()).toBe('Gizmos')
|
||||
keydown(document.activeElement!, 'ArrowDown')
|
||||
expect(active()).toBe('Widgets')
|
||||
keydown(document.activeElement!, 'ArrowUp')
|
||||
expect(active()).toBe('Gizmos')
|
||||
keydown(document.activeElement!, 'Home')
|
||||
expect(active()).toBe('Widgets')
|
||||
expect(keydown(menu, 'x').defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('asks the rail to close on Escape and reports Tab and navigation', async () => {
|
||||
const wrapper = await mountFlyout()
|
||||
const menu = wrapper.find('[role="menu"]').element
|
||||
expect(keydown(menu, 'Escape').defaultPrevented).toBe(true)
|
||||
keydown(menu, 'Tab')
|
||||
await wrapper.findAll('[role="menuitem"]')[2]!.trigger('click')
|
||||
expect(wrapper.emitted('escape')).toHaveLength(1)
|
||||
expect(wrapper.emitted('leave')).toHaveLength(1)
|
||||
expect(wrapper.emitted('navigate')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('tells whether an element sits inside it', async () => {
|
||||
const wrapper = await mountFlyout()
|
||||
const vm = wrapper.vm as unknown as { contains: (element: Element | null) => boolean }
|
||||
expect(vm.contains(wrapper.find('[role="menuitem"]').element)).toBe(true)
|
||||
expect(vm.contains(document.body)).toBe(false)
|
||||
expect(vm.contains(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
58
admin/tests/shell/SectionPanel.test.ts
Normal file
58
admin/tests/shell/SectionPanel.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import SectionPanel from '../../src/components/shell/SectionPanel.vue'
|
||||
import { SIDEBAR_KEY, setCollapsed, sidebarCollapsed } from '../../src/state/useSidebar'
|
||||
import { clone, navigationFixture } from '../fixtures/typed'
|
||||
import { resetState, routerAt, signIn } from '../helpers'
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
setCollapsed(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setCollapsed(false)
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
async function mountPanel(path: string, entry = navigationFixture.data[0]!) {
|
||||
await signIn()
|
||||
const router = await routerAt(path)
|
||||
const wrapper = mount(SectionPanel, { props: { entry }, global: { plugins: [router] }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('section panel', () => {
|
||||
it('is a labelled region with the plugin label and its permitted side menu', async () => {
|
||||
const wrapper = await mountPanel('/acme/demo/gadgets/4')
|
||||
expect(wrapper.find('aside').attributes('aria-label')).toBe('Sekcje')
|
||||
expect(wrapper.find('aside span').text()).toBe('Demo')
|
||||
const links = wrapper.findAll('a')
|
||||
expect(links.map((link) => [link.text(), link.attributes('href')])).toEqual([
|
||||
['Widgets', '/acme/demo/widgets'],
|
||||
['Gadgets', '/acme/demo/gadgets'],
|
||||
])
|
||||
expect(links[0]!.attributes('aria-current')).toBeUndefined()
|
||||
expect(links[1]!.attributes('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('skips side-menu items whose controller id is invalid', async () => {
|
||||
const entry = clone(navigationFixture.data[0]!)
|
||||
entry.sideMenu[0]!.controller = 'not a controller'
|
||||
const wrapper = await mountPanel('/acme/demo/gadgets', entry)
|
||||
expect(wrapper.findAll('a').map((link) => link.text())).toEqual(['Gadgets'])
|
||||
})
|
||||
|
||||
it('collapses on "Zwiń menu" and persists the choice', async () => {
|
||||
const wrapper = await mountPanel('/acme/demo/widgets')
|
||||
const button = wrapper.find('[data-sidebar-collapse]')
|
||||
expect(button.attributes('aria-label')).toBe('Zwiń menu')
|
||||
expect(button.attributes('type')).toBe('button')
|
||||
await button.trigger('click')
|
||||
expect(sidebarCollapsed.value).toBe(true)
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
|
||||
})
|
||||
})
|
||||
88
admin/tests/shell/UserMenu.test.ts
Normal file
88
admin/tests/shell/UserMenu.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import UserMenu from '../../src/components/shell/UserMenu.vue'
|
||||
import { currentUser } from '../../src/state/useAuth'
|
||||
import { API, profile, requestsTo, resetState, routerAt, signIn, wait } from '../helpers'
|
||||
|
||||
async function mountMenu(user: Partial<typeof profile.data> | null) {
|
||||
let calls: Request[] = []
|
||||
if (user) {
|
||||
calls = await signIn({
|
||||
[`GET ${API}/auth/me`]: { body: { data: { ...profile.data, ...user }, meta: {} } },
|
||||
[`POST ${API}/auth/logout`]: { body: { data: { status: 'logged_out' }, meta: {} } },
|
||||
})
|
||||
}
|
||||
const router = await routerAt('/acme/demo/widgets')
|
||||
const wrapper = mount(UserMenu, { global: { plugins: [router] }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
return { wrapper, router, calls }
|
||||
}
|
||||
|
||||
async function open(wrapper: ReturnType<typeof mount>): Promise<HTMLElement> {
|
||||
const trigger = wrapper.find('[data-user-menu]')
|
||||
await trigger.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
const content = document.querySelector<HTMLElement>('[data-user-menu-content]')
|
||||
expect(content).not.toBeNull()
|
||||
return content!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('user menu', () => {
|
||||
it('labels the trigger with the name and shows initials, name and role', async () => {
|
||||
const { wrapper } = await mountMenu({})
|
||||
const trigger = wrapper.find('[data-user-menu]')
|
||||
expect(trigger.attributes('aria-label')).toBe('Menu użytkownika: Dana Dev')
|
||||
expect(trigger.attributes('aria-haspopup')).toBe('menu')
|
||||
expect(trigger.attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.find('[data-user-initials]').text()).toBe('DD')
|
||||
expect(wrapper.find('[data-user-initials]').attributes('aria-hidden')).toBe('true')
|
||||
expect(wrapper.find('[data-user-role]').text()).toBe('Developer')
|
||||
})
|
||||
|
||||
it('falls back to the login for the name and initials and hides a missing role', async () => {
|
||||
const { wrapper } = await mountMenu({ first_name: ' ', last_name: '', login: 'root', role: undefined })
|
||||
expect(wrapper.find('[data-user-identity]').text()).toBe('root')
|
||||
expect(wrapper.find('[data-user-initials]').text()).toBe('RO')
|
||||
expect(wrapper.find('[data-user-role]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('uses a single initial for a single name', async () => {
|
||||
const { wrapper } = await mountMenu({ first_name: 'ada', last_name: '' })
|
||||
expect(wrapper.find('[data-user-initials]').text()).toBe('A')
|
||||
})
|
||||
|
||||
it('is disabled without a signed-in admin', async () => {
|
||||
const { wrapper } = await mountMenu(null)
|
||||
expect(currentUser.value).toBeNull()
|
||||
expect(wrapper.find('[data-user-menu]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('[data-user-initials]').text()).toBe('')
|
||||
})
|
||||
|
||||
it('opens a menu with the e-mail and logs out to the login route', async () => {
|
||||
const { wrapper, router, calls } = await mountMenu({})
|
||||
const content = await open(wrapper)
|
||||
expect(content.getAttribute('role')).toBe('menu')
|
||||
expect(content.textContent).toContain('dev@example.test')
|
||||
const item = content.querySelector<HTMLElement>('[data-action="logout"]')!
|
||||
expect(item.getAttribute('role')).toBe('menuitem')
|
||||
expect(item.textContent?.trim()).toBe('Wyloguj')
|
||||
|
||||
item.click()
|
||||
await flushPromises()
|
||||
await wait(0)
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'POST', `${API}/auth/logout`)).toHaveLength(1)
|
||||
expect(currentUser.value).toBeNull()
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user