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: '

content

' }, 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) }) })