import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils' import { setBundle } from '../../src/app/i18n' import { applyColorScheme, DARK_QUERY } from '../../src/app/theme' import { clearUser, currentUser } from '../../src/state/useAuth' import { navigation, setNavigation } from '../../src/state/useNavigation' import { setSettings } from '../../src/state/useSettings' import { NARROW_QUERY, SIDEBAR_KEY } from '../../src/state/useSidebar' import { clearToasts, showToast } from '../../src/state/useToasts' import lang from '../fixtures/lang.json' import formSchema from '../fixtures/widgets.form-schema.json' import record from '../fixtures/widgets.record.json' import listSchema from '../fixtures/widgets.list-schema.json' import listRows from '../fixtures/widgets.list.json' import { API, mountApp, requestsTo, type Route } from '../helpers' const BASE = `${API}/acme/demo/widgets` function routes(overrides: Record = {}): Record { return { [`GET ${BASE}/schema/list`]: { body: listSchema }, [`GET ${BASE}`]: { body: listRows }, [`GET ${BASE}/schema/form`]: { body: formSchema }, [`GET ${BASE}/1`]: { body: record }, [`POST ${API}/auth/logout`]: { body: { data: { status: 'logged_out' }, meta: {} } }, ...overrides, } } type Listener = (event: MediaQueryListEvent) => void /** A matchMedia whose answers the test controls, with change events. */ function fakeMedia(initial: Record) { const state = { ...initial } const listeners = new Map>() const of = (query: string): Set => { let set = listeners.get(query) if (!set) { set = new Set() listeners.set(query, set) } return set } const impl = (query: string): MediaQueryList => ({ get matches() { return state[query] ?? false }, media: query, onchange: null, addEventListener: (_type: string, listener: Listener) => of(query).add(listener), removeEventListener: (_type: string, listener: Listener) => of(query).delete(listener), addListener: () => undefined, removeListener: () => undefined, dispatchEvent: () => false, }) as unknown as MediaQueryList vi.spyOn(window, 'matchMedia').mockImplementation(impl) return { change(query: string, matches: boolean): void { state[query] = matches for (const listener of of(query)) { listener({ matches, media: query } as MediaQueryListEvent) } }, listeners: (query: string) => of(query).size, } } const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) function panel(wrapper: VueWrapper) { return wrapper.find('aside') } function railItem(wrapper: VueWrapper, code = 'demo'): HTMLElement { return wrapper.find(`[data-rail-item="${code}"]`).element as HTMLElement } function flyout(): HTMLElement | null { return document.body.querySelector('[data-section-flyout]') } function key(target: Element, name: string): void { target.dispatchEvent(new KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true })) } beforeEach(() => { clearUser() setNavigation([]) setSettings([]) clearToasts() setBundle(lang.data, 'pl') window.localStorage.clear() }) afterEach(() => { document.body.innerHTML = '' document.documentElement.classList.remove('dark') window.localStorage.clear() }) // Registered after the cleanup above, so it runs first (hooks run as a // stack): the app unmounts, with its timers and portals, before the body is // cleared. enableAutoUnmount(afterEach) describe('collapsible section panel', () => { it('persists the manual choice as a boolean under summer-admin.sidebar and restores it', async () => { const first = await mountApp('/acme/demo/widgets', routes(), { attach: true }) expect(panel(first.wrapper).exists()).toBe(true) expect(panel(first.wrapper).text()).toContain('Demo') expect(first.wrapper.find('[data-sidebar-expand]').exists()).toBe(false) await first.wrapper.find('[data-sidebar-collapse]').trigger('click') await flushPromises() expect(panel(first.wrapper).exists()).toBe(false) expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true') expect(document.activeElement?.hasAttribute('data-sidebar-expand')).toBe(true) first.wrapper.unmount() const second = await mountApp('/acme/demo/widgets', routes()) expect(panel(second.wrapper).exists()).toBe(false) const expand = second.wrapper.find('[data-sidebar-expand]') expect(expand.attributes('aria-label')).toBe('RozwiƄ menu') await expand.trigger('click') expect(panel(second.wrapper).exists()).toBe(true) expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false') // The sidebar preference is the only thing in browser storage. expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY]) }) it('collapses below 1100px without overwriting the stored preference', async () => { window.localStorage.setItem(SIDEBAR_KEY, 'false') const media = fakeMedia({ [NARROW_QUERY]: true }) const { wrapper } = await mountApp('/acme/demo/widgets', routes()) expect(panel(wrapper).exists()).toBe(false) // Expanding is impossible at this width, so no expand button. expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false) expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false') media.change(NARROW_QUERY, false) await flushPromises() expect(panel(wrapper).exists()).toBe(true) expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false') wrapper.unmount() expect(media.listeners(NARROW_QUERY)).toBe(0) }) }) describe('section flyout', () => { it('opens from a focused rail item with Enter, moves by arrows, and Esc returns focus', async () => { window.localStorage.setItem(SIDEBAR_KEY, 'true') const { wrapper } = await mountApp('/acme/demo/widgets', routes(), { attach: true }) const item = railItem(wrapper) expect(item.getAttribute('aria-haspopup')).toBe('menu') expect(item.getAttribute('aria-expanded')).toBe('false') item.focus() await flushPromises() expect(flyout()).not.toBeNull() key(item, 'Enter') await flushPromises() const menu = flyout()! expect(menu.getAttribute('role')).toBe('menu') expect(menu.getAttribute('aria-label')).toBe('Demo') expect(item.getAttribute('aria-expanded')).toBe('true') expect(item.getAttribute('aria-controls')).toBe(menu.id) const items = [...menu.querySelectorAll('[role="menuitem"]')] expect(items.map((element) => element.textContent?.trim())).toEqual(['Widgets', 'Gadgets']) expect(items[0]!.getAttribute('aria-current')).toBe('page') expect(document.activeElement).toBe(items[0]) key(items[0]!, 'ArrowDown') expect(document.activeElement).toBe(items[1]) key(items[1]!, 'Escape') await flushPromises() expect(flyout()).toBeNull() expect(document.activeElement).toBe(item) // Returning focus does not reopen it. await wait(250) expect(flyout()).toBeNull() }) it('opens with ArrowDown and on hover, and closes about 200 ms after mouse-leave', async () => { window.localStorage.setItem(SIDEBAR_KEY, 'true') const { wrapper } = await mountApp('/acme/demo/widgets', routes(), { attach: true }) const tools = railItem(wrapper, 'tools') tools.focus() key(tools, 'ArrowDown') await flushPromises() expect(flyout()!.getAttribute('aria-label')).toBe('Tools') expect(document.activeElement?.textContent?.trim()).toBe('Hammers') key(document.activeElement!, 'Escape') await flushPromises() const item = wrapper.find('[data-rail-item="demo"]') await item.trigger('mouseenter') expect(flyout()!.getAttribute('aria-label')).toBe('Demo') await item.trigger('mouseleave') await wait(100) expect(flyout()).not.toBeNull() // Moving onto the flyout keeps it open. flyout()!.dispatchEvent(new MouseEvent('mouseenter')) await wait(250) expect(flyout()).not.toBeNull() flyout()!.dispatchEvent(new MouseEvent('mouseleave')) await wait(250) await flushPromises() expect(flyout()).toBeNull() }) it('never opens while the panel is expanded', async () => { const { wrapper } = await mountApp('/acme/demo/widgets', routes(), { attach: true }) const item = railItem(wrapper) item.focus() key(item, 'Enter') await wrapper.find('[data-rail-item="demo"]').trigger('mouseenter') await flushPromises() expect(flyout()).toBeNull() expect(item.hasAttribute('aria-haspopup')).toBe(false) }) }) describe('header', () => { it('shows plugin, controller and record crumbs on a record route', async () => { const { wrapper } = await mountApp('/acme/demo/widgets/1', routes()) const crumbs = wrapper.findAll('header [data-crumb]') expect(crumbs.map((crumb) => crumb.text())).toEqual(['Demo', 'Widgets', 'Blue widget']) expect(crumbs[0]!.find('a').attributes('href')).toBe('/acme/demo/widgets') expect(crumbs[1]!.find('a').attributes('href')).toBe('/acme/demo/widgets') expect(crumbs[2]!.find('a').exists()).toBe(false) expect(crumbs[2]!.find('[aria-current="page"]').text()).toBe('Blue widget') expect(wrapper.findAll('header [data-crumb] svg')).toHaveLength(2) }) it('ends on the controller crumb on a list route', async () => { const { wrapper } = await mountApp('/acme/demo/widgets', routes()) const crumbs = wrapper.findAll('header [data-crumb]') expect(crumbs.map((crumb) => crumb.text())).toEqual(['Demo', 'Widgets']) expect(crumbs[1]!.find('[aria-current="page"]').text()).toBe('Widgets') }) it('shows initials, name and role, and the menu with the e-mail and Wyloguj', async () => { const { wrapper } = await mountApp('/acme/demo/widgets', routes(), { attach: true }) const trigger = wrapper.find('[data-user-menu]') expect(trigger.find('[data-user-initials]').text()).toBe('DD') expect(trigger.find('[data-user-identity]').text()).toContain('Dana Dev') expect(trigger.find('[data-user-role]').text()).toBe('Developer') expect(trigger.find('[data-user-identity]').classes()).toContain('max-[1099px]:hidden') await trigger.trigger('keydown', { key: 'Enter' }) await flushPromises() const menu = document.body.querySelector('[data-user-menu-content]')! expect(menu.getAttribute('role')).toBe('menu') expect(menu.textContent).toContain('dev@example.test') expect(menu.querySelector('[data-action="logout"]')!.getAttribute('role')).toBe('menuitem') expect(menu.querySelector('[data-action="logout"]')!.textContent?.trim()).toBe('Wyloguj') }) it.each([ ['succeeds', { body: { data: { status: 'logged_out' }, meta: {} } }], ['fails', { status: 500, body: { error: { code: 'server_error', message: 'Boom', details: {} } } }], ])('logs out when the call %s: POST /auth/logout, state cleared, login screen', async (_name, reply) => { const { wrapper, calls, router } = await mountApp('/acme/demo/widgets', routes({ [`POST ${API}/auth/logout`]: reply }), { attach: true, }) await wrapper.find('[data-user-menu]').trigger('keydown', { key: 'Enter' }) await flushPromises() ;(document.body.querySelector('[data-action="logout"]') as HTMLElement).click() await flushPromises() const [post] = requestsTo(calls, 'POST', `${API}/auth/logout`) expect(post).toBeDefined() expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest') expect(currentUser.value).toBeNull() expect(navigation.value).toEqual([]) expect(router.currentRoute.value.name).toBe('login') expect(wrapper.find('aside').exists()).toBe(false) }) it('logs out to the login screen even when the network fails', async () => { const { wrapper, router } = await mountApp('/acme/demo/widgets', routes({ [`POST ${API}/auth/logout`]: () => { throw new TypeError('network down') }, }), { attach: true }) await wrapper.find('[data-user-menu]').trigger('keydown', { key: 'Enter' }) await flushPromises() ;(document.body.querySelector('[data-action="logout"]') as HTMLElement).click() await flushPromises() expect(currentUser.value).toBeNull() expect(router.currentRoute.value.name).toBe('login') }) }) describe('theme and toasts', () => { it('toggles the dark class from prefers-color-scheme and follows changes', () => { const media = fakeMedia({ [DARK_QUERY]: true }) const stop = applyColorScheme() expect(document.documentElement.classList.contains('dark')).toBe(true) media.change(DARK_QUERY, false) expect(document.documentElement.classList.contains('dark')).toBe(false) media.change(DARK_QUERY, true) expect(document.documentElement.classList.contains('dark')).toBe(true) stop() expect(media.listeners(DARK_QUERY)).toBe(0) }) it('stays light when the system prefers light', () => { fakeMedia({ [DARK_QUERY]: false }) applyColorScheme()() expect(document.documentElement.classList.contains('dark')).toBe(false) }) it('shows the most recent toast first', async () => { const { wrapper } = await mountApp('/acme/demo/widgets', routes()) showToast('First') showToast('Second', 'danger') await flushPromises() const texts = wrapper.findAll('[data-tone]').map((toast) => toast.text()) expect(texts[0]).toContain('Second') expect(texts[1]).toContain('First') }) })