import { describe, it, expect, afterEach, beforeEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import uiPlugin from '@nuxt/ui/vue-plugin' import router from '@/router' import AuthShell from '@/components/AuthShell.vue' import LoginView from '@/views/LoginView.vue' import { resetAuthShellNavigation } from '@/composables/useAuthShellNavigation' function stubMatchMedia(belowSm: boolean) { window.matchMedia = ((query: string) => ({ matches: belowSm, media: query, onchange: null, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, })) as unknown as typeof window.matchMedia } function mockResponse(status: number, body: unknown = null) { return { ok: status >= 200 && status < 300, status, headers: { get: () => null }, json: async () => body, } as unknown as Response } let currentWrapper: Awaited> | null = null async function mountLogin(path = '/login') { setActivePinia(createPinia()) global.fetch = (() => mockResponse(200, { open: false })) as unknown as typeof fetch await router.push(path) const wrapper = mount(LoginView, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) currentWrapper = wrapper await flushPromises() return wrapper } beforeEach(() => { resetAuthShellNavigation() }) afterEach(async () => { currentWrapper?.unmount() currentWrapper = null await router.push('/login') resetAuthShellNavigation() }) describe('"Forgot password?" is underlined and muted', () => { it('uses the muted toned color, underline and 3px offset, no other emphasis', async () => { const wrapper = await mountLogin() const link = wrapper.find('a[href="/register/reset/request"]') expect(link.classes()).toContain('text-toned') expect(link.classes()).toContain('underline') expect(link.classes()).toContain('underline-offset-[3px]') expect(link.classes()).not.toContain('text-primary') expect(link.classes()).not.toContain('font-bold') expect(link.classes()).not.toContain('font-semibold') }) }) describe('A field error shows the circle-alert icon with no extra focus halo', () => { it('precedes both field errors with the error-colored icon, and keeps a single error ring when focused', async () => { const wrapper = await mountLogin() await wrapper.find('input[placeholder="Your username"]').setValue('ma') await wrapper.find('form').trigger('submit') await flushPromises() const fieldRoots = wrapper.findAll( '[data-slot="root"][data-orientation]', ) const usernameError = fieldRoots[0]!.find('[data-slot="error"]') const passwordError = fieldRoots[1]!.find('[data-slot="error"]') for (const error of [usernameError, passwordError]) { const icon = error.find('svg') expect(icon.exists()).toBe(true) expect(icon.html()).toContain('M12 8v4m0 4h.01') expect(icon.classes()).toContain('text-error') } const usernameInput = wrapper.find('input[placeholder="Your username"]') await usernameInput.trigger('focus') expect(usernameInput.classes()).toContain('ring-error') expect(usernameInput.classes()).toContain('focus-visible:ring-error') expect(usernameInput.classes()).not.toContain('ring-primary') expect(usernameInput.classes()).not.toContain( 'focus-visible:ring-primary', ) }) }) describe('The theme select is right-aligned in the footer with no chevron', () => { it('renders as the last child, showing only the mode icon and label', async () => { stubMatchMedia(false) const wrapper = await mountLogin() const footer = wrapper.find('footer') const select = footer.find('button[aria-haspopup="listbox"]') expect(select.exists()).toBe(true) const children = Array.from(footer.element.children) expect(children[children.length - 1]).toBe(select.element) expect(select.text()).toContain('System') const trailing = select.find('[data-slot="trailing"]') expect(trailing.classes()).toContain('hidden') }) }) describe('The alert renders inside the card, under the description', () => { it.each([ { path: '/login?redirect=/article/12/edit&reason=expired', state: 'Session expired', text: 'Your session expired', }, { path: '/login?reason=password-changed', state: 'Password changed', text: 'Password changed', }, ])( 'places the $state alert inside the card, after the title/description, before Username', async ({ path, text }) => { const wrapper = await mountLogin(path) const card = wrapper.find('[data-testid="auth-card"]') const alerts = wrapper.findAll('[role="alert"], [role="status"]') expect(alerts.length).toBe(1) const alert = alerts[0]! expect(card.element.contains(alert.element)).toBe(true) expect(alert.text()).toContain(text) const rootsInCard = card.findAll( '[data-slot="root"][data-orientation]', ) expect( rootsInCard.some((root) => root.classes().includes('bg-primary'), ), ).toBe(false) const usernameInput = wrapper.find( 'input[placeholder="Your username"]', ) const html = card.html() expect(html.indexOf('Welcome back')).toBeGreaterThan(-1) expect(html.indexOf('Welcome back')).toBeLessThan( html.indexOf(text), ) expect(html.indexOf(text)).toBeLessThan(html.indexOf('Username')) expect(card.element.contains(usernameInput.element)).toBe(true) expect( alert .classes() .some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c)), ).toBe(true) }, ) }) describe('The h1 is not focused on first load', () => { it('leaves focus on the document body after AuthShell mounts', () => { const wrapper = mount(AuthShell, { props: { title: 'Log in', description: 'Welcome back.' }, attachTo: document.body, global: { plugins: [uiPlugin] }, }) const heading = wrapper.find('h1') expect(heading.element).not.toBe(document.activeElement) wrapper.unmount() }) }) describe('Focus moves to the h1 only on an in-app navigation between public screens', () => { it('does not focus the first AuthShell mount, but focuses the second', () => { const first = mount(AuthShell, { props: { title: 'Log in', description: 'Welcome back.' }, attachTo: document.body, global: { plugins: [uiPlugin] }, }) expect(first.find('h1').element).not.toBe(document.activeElement) first.unmount() const second = mount(AuthShell, { props: { title: 'Forgot password?', description: 'Reset it.' }, attachTo: document.body, global: { plugins: [uiPlugin] }, }) expect(second.find('h1').element).toBe(document.activeElement) expect(second.find('h1').classes()).toContain('outline-none') second.unmount() }) })