Files
summercms/admin/tests/smoke/shell.smoke.test.ts
Jakub Zych 445404e394 feat(10-04): collapsible panel with flyout, user menu with logout, breadcrumbs and dark mode
- useSidebar: collapsed below 1100px (matchMedia) or by the admin's choice,
  persisted as a boolean under summer-admin.sidebar; the viewport never
  overwrites the stored choice
- SectionPanel collapse and rail expand buttons; SectionFlyout (role menu)
  opens on hover, focus, Enter or ArrowDown on a rail item, closes on Esc or
  about 200 ms after leaving and returns focus to the rail item
- Breadcrumbs with plugin, controller and record crumbs; UserMenu (Reka
  DropdownMenu) with initials, name, role and Wyloguj
- useAuth.logout POSTs /auth/logout, clears user, navigation and settings
  and routes to login even when the call fails
- applyColorScheme toggles .dark from prefers-color-scheme (no toggle, A6)
- newest toast first, 200 ms fade and scale for dialogs and toasts
- one relation-manager type constant in the registry
- shell smoke tests; tests default to a desktop, light matchMedia
2026-09-27 17:26:44 +02:00

340 lines
13 KiB
TypeScript

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<string, Route> = {}): Record<string, Route> {
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<string, boolean>) {
const state = { ...initial }
const listeners = new Map<string, Set<Listener>>()
const of = (query: string): Set<Listener> => {
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<HTMLElement>('[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')
})
})