Files
summercms/admin/tests/shell/AppShell.test.ts
Jakub Zych 85476fa3fe test(quick-261022-imo): settings section panel coverage
- settingsSectionOf: null cases, category order, link entry placement
- SettingsPanel: labels, links, current entry, empty category, collapse
- AppShell: hidden cases, settings-link controllers, collapse, offset, narrow, focus hand-off
2026-10-22 13:52:20 +02:00

236 lines
10 KiB
TypeScript

import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
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 { setSettings } from '../../src/state/useSettings'
import { NARROW_QUERY, setCollapsed } from '../../src/state/useSidebar'
import { showToast } from '../../src/state/useToasts'
import { navigationFixture } from '../fixtures/typed'
import { setBundle } from '../../src/app/i18n'
import { fakeMedia, resetState, routerAt, signIn } from '../helpers'
import { langFixture, settingsFixture } from '../fixtures/typed'
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')
})
})
describe('View site link (Phase 16.2 quick follow-up)', () => {
it('opens the site root in a new tab without an opener or referrer', async () => {
const wrapper = await mountShell('/acme/demo/widgets')
const link = wrapper.find('header a[data-view-site]')
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toBe(`${window.location.origin}/`)
expect(link.attributes('target')).toBe('_blank')
expect(link.attributes('rel')?.split(' ').sort()).toEqual(['noopener', 'noreferrer'])
// It sits next to the user menu, inside the header.
expect(link.element.nextElementSibling?.matches('[data-user-menu], :has([data-user-menu])')).toBe(true)
expect(link.find('svg').attributes('aria-hidden')).toBe('true')
})
it.each([
['pl', 'Zobacz stronę'],
['en', 'View site'],
])('labels the link from the phrasebook in %s', async (locale, label) => {
setBundle({ ...langFixture.data, 'backend::lang.nav.view_site': { other: label } }, locale)
const wrapper = await mountShell('/acme/demo/widgets')
const link = wrapper.find('a[data-view-site]')
expect(link.attributes('aria-label')).toBe(label)
expect(link.attributes('title')).toBe(label)
})
it('has the label key in the backend phrasebook for en and pl', () => {
for (const [locale, label] of [
['en', 'View site'],
['pl', 'Zobacz stronę'],
]) {
// vitest runs from the admin directory.
const yaml = readFileSync(resolve(process.cwd(), '../modules/phrasebook/backend/lang', locale!, 'lang.yaml'), 'utf8')
expect(yaml).toMatch(new RegExp(`^nav:[\\s\\S]*?^ view_site: ${label}$`, 'm'))
}
})
it('exposes --shell-offset: the rail, plus the section panel when it shows', async () => {
const wrapper = await mountShell('/acme/demo/widgets')
const root = wrapper.element as HTMLElement
expect(root.style.getPropertyValue('--shell-offset')).toBe('calc(var(--spacing-rail) + var(--spacing-panel))')
setCollapsed(true)
await flushPromises()
expect(root.style.getPropertyValue('--shell-offset')).toBe('var(--spacing-rail)')
setCollapsed(false)
await flushPromises()
expect(root.style.getPropertyValue('--shell-offset')).toBe('calc(var(--spacing-rail) + var(--spacing-panel))')
})
it('offsets by the rail alone without a section panel', async () => {
const wrapper = await mountShell('/settings')
expect((wrapper.element as HTMLElement).style.getPropertyValue('--shell-offset')).toBe('var(--spacing-rail)')
})
})
describe('settings section panel', () => {
it('lists the current category on a settings form, current entry highlighted', async () => {
setSettings(settingsFixture.list.data)
const wrapper = await mountShell('/settings/mail')
const panel = wrapper.find('aside[data-settings-panel]')
expect(panel.exists()).toBe(true)
expect(panel.find('span').text()).toBe('System')
const links = panel.findAll('[data-settings-panel-item]')
expect(links.map((link) => [link.text(), link.attributes('href')])).toEqual([
['Mail', '/settings/mail'],
['Search', '/settings/search'],
])
expect(links[0]!.attributes('aria-current')).toBe('page')
expect(links[1]!.attributes('aria-current')).toBeUndefined()
})
const link = (code: string, label: string, controller: string) => ({
...settingsFixture.list.data[0]!,
code,
label,
model: '',
controller,
order: 15,
})
it('is hidden for a one-entry category and on the settings index', async () => {
setSettings(settingsFixture.list.data)
const branding = await mountShell('/settings/branding')
expect(branding.find('[data-settings-panel]').exists()).toBe(false)
expect(branding.find('aside').exists()).toBe(false)
branding.unmount()
const index = await mountShell('/settings')
expect(index.find('aside').exists()).toBe(false)
})
it('never lists an entry of another category', async () => {
setSettings(settingsFixture.list.data)
const wrapper = await mountShell('/settings/mail')
expect(wrapper.find('[data-settings-panel-item="branding"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('Branding')
})
it.each(['/acme/lang/locales', '/acme/lang/locales/5'])(
'shows on a settings-link controller without its own navigation entry (%s)',
async (path) => {
setSettings([...settingsFixture.list.data, link('locales', 'Locales', 'acme.lang.locales')])
const wrapper = await mountShell(path)
const panel = wrapper.find('aside[data-settings-panel]')
expect(panel.exists()).toBe(true)
expect(panel.find('[aria-current="page"]').text()).toBe('Locales')
expect(panel.findAll('[data-settings-panel-item]').map((item) => item.text())).toEqual(['Mail', 'Locales', 'Search'])
},
)
it('keeps the plugin panel on a settings-link controller with its own navigation entry', async () => {
setSettings([...settingsFixture.list.data, link('widgets', 'Widgets', 'acme.demo.widgets')])
const wrapper = await mountShell('/acme/demo/widgets')
expect(wrapper.find('[data-settings-panel]').exists()).toBe(false)
expect(wrapper.find('aside span').text()).toBe('Demo')
})
it('collapses with the sidebar and offsets the shell like the plugin panel', async () => {
setSettings(settingsFixture.list.data)
const wrapper = await mountShell('/settings/mail')
const root = wrapper.element as HTMLElement
expect(root.style.getPropertyValue('--shell-offset')).toBe('calc(var(--spacing-rail) + var(--spacing-panel))')
setCollapsed(true)
await flushPromises()
expect(wrapper.find('[data-settings-panel]').exists()).toBe(false)
expect(root.style.getPropertyValue('--shell-offset')).toBe('var(--spacing-rail)')
setCollapsed(false)
await flushPromises()
expect(wrapper.find('[data-settings-panel]').exists()).toBe(true)
expect(root.style.getPropertyValue('--shell-offset')).toBe('calc(var(--spacing-rail) + var(--spacing-panel))')
})
it('is hidden on a narrow viewport', async () => {
fakeMedia({ [NARROW_QUERY]: true })
setSettings(settingsFixture.list.data)
const wrapper = await mountShell('/settings/mail')
expect(wrapper.find('[data-settings-panel]').exists()).toBe(false)
expect((wrapper.element as HTMLElement).style.getPropertyValue('--shell-offset')).toBe('var(--spacing-rail)')
})
it('hands focus to the rail expand button and back', async () => {
setSettings(settingsFixture.list.data)
const wrapper = await mountShell('/settings/mail')
await wrapper.find('[data-settings-panel] [data-sidebar-collapse]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-settings-panel]').exists()).toBe(false)
expect(document.activeElement?.matches('[data-sidebar-expand]')).toBe(true)
await wrapper.find('[data-sidebar-expand]').trigger('click')
await flushPromises()
expect(wrapper.find('aside[data-settings-panel]').exists()).toBe(true)
expect(document.activeElement?.matches('[data-sidebar-collapse]')).toBe(true)
})
})