213 lines
6.6 KiB
TypeScript
213 lines
6.6 KiB
TypeScript
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<ReturnType<typeof mount>> | 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()
|
|
})
|
|
})
|