feat: article editor core
This commit is contained in:
1 parent
d493a181e1
commit
a4734b2c91
55 files changed
+4186
-496
No files matched your search
@@ -8,7 +8,10 @@ beforeEach(() => {
|
||||
resetAuthShellNavigation()
|
||||
})
|
||||
|
||||
function mountShell(props: Record<string, unknown>, slots: Record<string, unknown> = {}) {
|
||||
function mountShell(
|
||||
props: Record<string, unknown>,
|
||||
slots: Record<string, unknown> = {},
|
||||
) {
|
||||
return mount(AuthShell, {
|
||||
props,
|
||||
slots,
|
||||
@@ -63,13 +66,17 @@ describe('AuthShell renders its notice and footer slots', () => {
|
||||
|
||||
const notice = wrapper.find('span')
|
||||
expect(notice.text()).toBe('Your session expired.')
|
||||
expect(wrapper.findAll('[data-slot="root"][data-orientation]').length).toBe(1)
|
||||
expect(
|
||||
wrapper.findAll('[data-slot="root"][data-orientation]').length,
|
||||
).toBe(1)
|
||||
|
||||
const card = wrapper.find('[data-testid="auth-card"]')
|
||||
expect(card.element.contains(notice.element)).toBe(true)
|
||||
|
||||
const html = wrapper.html()
|
||||
expect(html.indexOf('<h1')).toBeLessThan(html.indexOf('Your session expired.'))
|
||||
expect(html.indexOf('<h1')).toBeLessThan(
|
||||
html.indexOf('Your session expired.'),
|
||||
)
|
||||
|
||||
const footerLink = wrapper.find('a[href="/register/reset/request"]')
|
||||
expect(footerLink.exists()).toBe(true)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import CharacterCounter from '../CharacterCounter.vue'
|
||||
|
||||
function mountCounter(value: number) {
|
||||
return mount(CharacterCounter, {
|
||||
props: { value, max: 10000 },
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Below the warning threshold', () => {
|
||||
it('shows the plain count with no icon', () => {
|
||||
const wrapper = mountCounter(4318)
|
||||
|
||||
expect(wrapper.text()).toContain('4 318 / 10 000')
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-testid="character-counter-warning-icon"]')
|
||||
.exists(),
|
||||
).toBe(false)
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-testid="character-counter-over-icon"]')
|
||||
.exists(),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Near the limit — 90% or more', () => {
|
||||
it('shows the warning icon without an over-limit message', () => {
|
||||
const wrapper = mountCounter(9240)
|
||||
|
||||
expect(wrapper.text()).toContain('9 240 / 10 000')
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-testid="character-counter-warning-icon"]')
|
||||
.exists(),
|
||||
).toBe(true)
|
||||
expect(wrapper.text()).not.toContain('characters over')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Too long — over the max', () => {
|
||||
it('shows the over-limit icon and the exact overage copy', () => {
|
||||
const wrapper = mountCounter(10214)
|
||||
|
||||
expect(wrapper.text()).toContain('10 214 / 10 000')
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-testid="character-counter-over-icon"]')
|
||||
.exists(),
|
||||
).toBe(true)
|
||||
expect(wrapper.text()).toContain(
|
||||
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -74,9 +74,14 @@ describe('Cancelling — the action is in flight and the dialog cannot be dismis
|
||||
await clickButton('Cancel registration')
|
||||
|
||||
expect(action).toHaveBeenCalledTimes(1)
|
||||
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(
|
||||
(findButtonElement('Keep my account') as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true)
|
||||
expect(findButtonElement('Cancelling…')).toBeTruthy()
|
||||
expect((findButtonElement('Cancelling…') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(
|
||||
(findButtonElement('Cancelling…') as HTMLButtonElement).disabled,
|
||||
).toBe(true)
|
||||
expect(document.querySelector('[aria-busy="true"]')).toBeTruthy()
|
||||
|
||||
resolveAction?.()
|
||||
@@ -112,18 +117,72 @@ describe('Cancelled — resolving the action closes the dialog', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dismissing (Esc or the overlay) defaults to the cancel outcome', () => {
|
||||
it('emits close(false) without calling the action', async () => {
|
||||
const action = vi.fn(() => Promise.resolve())
|
||||
const wrapper = mountDialog(action)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.setProps({ open: false })
|
||||
|
||||
expect(action).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('close')).toEqual([[false]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dismissResolvesTo="confirm" makes Esc/overlay resolve as confirmed', () => {
|
||||
it('emits close(true) on dismiss without calling the action', async () => {
|
||||
const action = vi.fn(() => Promise.resolve())
|
||||
const wrapper = mount(ConfirmDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
title: 'Leave without saving?',
|
||||
description:
|
||||
'Your changes to "How we price a bathroom renovation" haven’t been saved.',
|
||||
confirmLabel: 'Keep editing',
|
||||
cancelLabel: 'Discard changes',
|
||||
pendingLabel: 'Keep editing',
|
||||
tone: 'neutral',
|
||||
action,
|
||||
dismissResolvesTo: 'confirm',
|
||||
},
|
||||
global: {
|
||||
plugins: [uiPlugin],
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.setProps({ open: false })
|
||||
|
||||
expect(action).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('close')).toEqual([[true]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancel failed — the dialog stays open and shows the failure inline', () => {
|
||||
it('re-enables both buttons and shows the rejection message inline', async () => {
|
||||
const action = vi.fn(() => Promise.reject(new Error("Couldn't cancel registration.")))
|
||||
const action = vi.fn(() =>
|
||||
Promise.reject(new Error("Couldn't cancel registration.")),
|
||||
)
|
||||
const wrapper = mountDialog(action)
|
||||
await flushPromises()
|
||||
|
||||
await clickButton('Cancel registration')
|
||||
|
||||
expect(wrapper.emitted('close')).toBeFalsy()
|
||||
expect(document.body.textContent).toContain("Couldn't cancel registration.")
|
||||
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(false)
|
||||
expect((findButtonElement('Cancel registration') as HTMLButtonElement).disabled).toBe(false)
|
||||
expect(document.body.textContent).toContain(
|
||||
"Couldn't cancel registration.",
|
||||
)
|
||||
expect(
|
||||
(findButtonElement('Keep my account') as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false)
|
||||
expect(
|
||||
(findButtonElement('Cancel registration') as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -56,7 +56,9 @@ describe('DashboardShell renders the three navigation destinations', () => {
|
||||
|
||||
describe('DashboardShell shows the offline banner when the browser reports offline', () => {
|
||||
it('shows the banner while offline and hides it once back online', async () => {
|
||||
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
||||
const onLineSpy = vi
|
||||
.spyOn(navigator, 'onLine', 'get')
|
||||
.mockReturnValue(false)
|
||||
|
||||
const wrapper = await mountShell('/article')
|
||||
|
||||
@@ -77,12 +79,16 @@ describe('The sidebar header renders AppLogo and the "RenewCMS" wordmark', () =>
|
||||
const wrapper = await mountShell('/home')
|
||||
|
||||
const header = wrapper.find('[data-slot="header"]')
|
||||
expect(header.find('svg[role="img"][aria-label="RenewCMS"]').exists()).toBe(true)
|
||||
expect(
|
||||
header.find('svg[role="img"][aria-label="RenewCMS"]').exists(),
|
||||
).toBe(true)
|
||||
expect(header.text()).toContain('RenewCMS')
|
||||
expect(header.find('a').exists()).toBe(false)
|
||||
|
||||
const headerHtml = header.html()
|
||||
expect(headerHtml.indexOf('<svg')).toBeLessThan(headerHtml.indexOf('RenewCMS</span>'))
|
||||
expect(headerHtml.indexOf('<svg')).toBeLessThan(
|
||||
headerHtml.indexOf('RenewCMS</span>'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -91,7 +97,9 @@ describe('The sidebar collapse trigger toggles the rail and exposes aria-expande
|
||||
await mountShell('/home')
|
||||
|
||||
const collapseButton = () =>
|
||||
document.body.querySelector('[data-testid="sidebar-collapse"]') as HTMLButtonElement
|
||||
document.body.querySelector(
|
||||
'[data-testid="sidebar-collapse"]',
|
||||
) as HTMLButtonElement
|
||||
|
||||
expect(collapseButton().getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
@@ -112,11 +120,13 @@ describe('A successful logout clears the session and navigates to /login', () =>
|
||||
await mountShell('/home')
|
||||
|
||||
const logOutItem = () =>
|
||||
Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
|
||||
(el) => el.textContent?.trim() === 'Log out',
|
||||
) as HTMLElement
|
||||
Array.from(
|
||||
document.body.querySelectorAll('[role="menuitem"]'),
|
||||
).find((el) => el.textContent?.trim() === 'Log out') as HTMLElement
|
||||
|
||||
const trigger = document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
|
||||
const trigger = document.body.querySelector(
|
||||
'button[aria-haspopup]',
|
||||
) as HTMLButtonElement
|
||||
trigger.click()
|
||||
await flushPromises()
|
||||
|
||||
@@ -141,16 +151,28 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac
|
||||
collapseButton.click()
|
||||
await flushPromises()
|
||||
|
||||
const header = document.body.querySelector('[data-slot="header"]') as HTMLElement
|
||||
expect(header.querySelector('svg[role="img"][aria-label="RenewCMS"]')).toBeTruthy()
|
||||
const header = document.body.querySelector(
|
||||
'[data-slot="header"]',
|
||||
) as HTMLElement
|
||||
expect(
|
||||
header.querySelector('svg[role="img"][aria-label="RenewCMS"]'),
|
||||
).toBeTruthy()
|
||||
expect(header.textContent).not.toContain('RenewCMS')
|
||||
expect(header.querySelector('span[aria-hidden="true"]')).toBeNull()
|
||||
|
||||
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
|
||||
const footer = document.body.querySelector(
|
||||
'[data-slot="footer"]',
|
||||
) as HTMLElement
|
||||
expect(footer.textContent).not.toContain('marta')
|
||||
|
||||
const userMenuTrigger = footer.querySelector('button[aria-haspopup]') as HTMLButtonElement
|
||||
expect(userMenuTrigger.getAttribute('aria-label')).toBe('marta — account menu')
|
||||
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
||||
const userMenuTrigger = footer.querySelector(
|
||||
'button[aria-haspopup]',
|
||||
) as HTMLButtonElement
|
||||
expect(userMenuTrigger.getAttribute('aria-label')).toBe(
|
||||
'marta — account menu',
|
||||
)
|
||||
expect(
|
||||
footer.querySelector('[data-slot="fallback"]')?.textContent,
|
||||
).toBe('MA')
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,9 @@ async function mountNotFound(breadcrumb?: string) {
|
||||
const router = makeRouter()
|
||||
await router.push('/')
|
||||
return mount(ErrorView, {
|
||||
props: breadcrumb ? { kind: 'not-found', breadcrumb } : { kind: 'not-found' },
|
||||
props: breadcrumb
|
||||
? { kind: 'not-found', breadcrumb }
|
||||
: { kind: 'not-found' },
|
||||
global: {
|
||||
plugins: [router],
|
||||
},
|
||||
@@ -29,20 +31,30 @@ describe('ErrorView not-found kind', () => {
|
||||
it('renders the not-found copy with a breadcrumb, primary and secondary actions', async () => {
|
||||
const wrapper = await mountNotFound('Not found')
|
||||
|
||||
expect(wrapper.find('nav[aria-label="Breadcrumb"]').text()).toBe('Not found')
|
||||
expect(wrapper.find('nav[aria-label="Breadcrumb"]').text()).toBe(
|
||||
'Not found',
|
||||
)
|
||||
expect(wrapper.find('h1').text()).toBe('Page not found')
|
||||
expect(wrapper.text()).toContain('The link may be mistyped, or the page may have moved.')
|
||||
expect(wrapper.text()).toContain(
|
||||
'The link may be mistyped, or the page may have moved.',
|
||||
)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons.some((button) => button.text() === 'Go to Home')).toBe(true)
|
||||
expect(buttons.some((button) => button.text() === 'All articles')).toBe(true)
|
||||
expect(buttons.some((button) => button.text() === 'Go to Home')).toBe(
|
||||
true,
|
||||
)
|
||||
expect(buttons.some((button) => button.text() === 'All articles')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('routes the primary action to /home and the secondary action to /article', async () => {
|
||||
const wrapper = await mountNotFound()
|
||||
const router = wrapper.vm.$router
|
||||
|
||||
const primary = wrapper.findAll('button').find((button) => button.text() === 'Go to Home')
|
||||
const primary = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'Go to Home')
|
||||
await primary?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/home')
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import PublishControl from '../PublishControl.vue'
|
||||
|
||||
function mountControl(props: {
|
||||
online: boolean
|
||||
dirty: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return mount(PublishControl, {
|
||||
props,
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Draft, clean', () => {
|
||||
it('reads "Publish"', () => {
|
||||
const wrapper = mountControl({ online: false, dirty: false })
|
||||
|
||||
expect(wrapper.text()).toBe('Publish')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Draft, dirty', () => {
|
||||
it('reads "Save and publish"', () => {
|
||||
const wrapper = mountControl({ online: false, dirty: true })
|
||||
|
||||
expect(wrapper.text()).toBe('Save and publish')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Online', () => {
|
||||
it('reads "Unpublish" regardless of dirty state', () => {
|
||||
const wrapper = mountControl({ online: true, dirty: true })
|
||||
|
||||
expect(wrapper.text()).toBe('Unpublish')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled', () => {
|
||||
it('disables the button', () => {
|
||||
const wrapper = mountControl({
|
||||
online: false,
|
||||
dirty: false,
|
||||
disabled: true,
|
||||
})
|
||||
|
||||
expect(
|
||||
(wrapper.find('button').element as HTMLButtonElement).disabled,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import SaveStatus from '../SaveStatus.vue'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Saved within the hour', () => {
|
||||
it('reads "Saved at HH:MM"', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-09-26T11:10:00Z'))
|
||||
const wrapper = mount(SaveStatus, {
|
||||
props: {
|
||||
state: 'saved',
|
||||
savedAt: new Date('2026-09-26T11:02:00Z'),
|
||||
},
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Saved at 11:02')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saved over an hour ago', () => {
|
||||
it('reads "Saved n min ago"', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-09-26T12:32:00Z'))
|
||||
const wrapper = mount(SaveStatus, {
|
||||
props: {
|
||||
state: 'saved',
|
||||
savedAt: new Date('2026-09-26T11:02:00Z'),
|
||||
},
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Saved 90 min ago')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dirty', () => {
|
||||
it('reads "Unsaved changes"', () => {
|
||||
const wrapper = mount(SaveStatus, {
|
||||
props: { state: 'dirty' },
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Unsaved changes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saving', () => {
|
||||
it('reads "Saving…"', () => {
|
||||
const wrapper = mount(SaveStatus, {
|
||||
props: { state: 'saving' },
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Saving…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error', () => {
|
||||
it('reads "Save failed" and emits retry from the Retry button', async () => {
|
||||
const wrapper = mount(SaveStatus, {
|
||||
props: { state: 'error' },
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Save failed')
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('retry')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Offline', () => {
|
||||
it('reads "Waiting for connection"', () => {
|
||||
const wrapper = mount(SaveStatus, {
|
||||
props: { state: 'offline' },
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Waiting for connection')
|
||||
})
|
||||
})
|
||||
@@ -28,7 +28,9 @@ function findMenuItemsByRole(role: string) {
|
||||
}
|
||||
|
||||
function findCheckboxItem(label: string) {
|
||||
return findMenuItemsByRole('menuitemcheckbox').find((el) => el.textContent?.trim() === label)
|
||||
return findMenuItemsByRole('menuitemcheckbox').find(
|
||||
(el) => el.textContent?.trim() === label,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -56,8 +58,8 @@ describe("UserMenu's dropdown lists the account label, color-mode checkboxes, th
|
||||
|
||||
expect(groups[0].textContent).toContain('marta')
|
||||
|
||||
const checkboxLabels = findMenuItemsByRole('menuitemcheckbox').map((el) =>
|
||||
el.textContent?.trim(),
|
||||
const checkboxLabels = findMenuItemsByRole('menuitemcheckbox').map(
|
||||
(el) => el.textContent?.trim(),
|
||||
)
|
||||
expect(checkboxLabels).toEqual(['System', 'Light', 'Dark'])
|
||||
|
||||
@@ -82,9 +84,15 @@ describe("UserMenu's dropdown lists the account label, color-mode checkboxes, th
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: false })
|
||||
await openMenu()
|
||||
|
||||
expect(findCheckboxItem('Dark')?.getAttribute('aria-checked')).toBe('true')
|
||||
expect(findCheckboxItem('Light')?.getAttribute('aria-checked')).toBe('false')
|
||||
expect(findCheckboxItem('System')?.getAttribute('aria-checked')).toBe('false')
|
||||
expect(findCheckboxItem('Dark')?.getAttribute('aria-checked')).toBe(
|
||||
'true',
|
||||
)
|
||||
expect(findCheckboxItem('Light')?.getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
)
|
||||
expect(findCheckboxItem('System')?.getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -105,15 +113,23 @@ describe('Selecting a color-mode item updates the preference and its checked sta
|
||||
const label = to.charAt(0).toUpperCase() + to.slice(1)
|
||||
const fromLabel = from.charAt(0).toUpperCase() + from.slice(1)
|
||||
|
||||
findCheckboxItem(label)?.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||||
findCheckboxItem(label)?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
findCheckboxItem(label)?.dispatchEvent(
|
||||
new MouseEvent('pointerup', { bubbles: true }),
|
||||
)
|
||||
findCheckboxItem(label)?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(colorMode.preference).toBe(to)
|
||||
|
||||
await openMenu()
|
||||
expect(findCheckboxItem(label)?.getAttribute('aria-checked')).toBe('true')
|
||||
expect(findCheckboxItem(fromLabel)?.getAttribute('aria-checked')).toBe('false')
|
||||
expect(findCheckboxItem(label)?.getAttribute('aria-checked')).toBe(
|
||||
'true',
|
||||
)
|
||||
expect(findCheckboxItem(fromLabel)?.getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -124,9 +140,9 @@ describe('A successful logout clears the session and navigates to /login', () =>
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: false })
|
||||
await openMenu()
|
||||
|
||||
const logOutItem = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
|
||||
(el) => el.textContent?.trim() === 'Log out',
|
||||
)
|
||||
const logOutItem = Array.from(
|
||||
document.body.querySelectorAll('[role="menuitem"]'),
|
||||
).find((el) => el.textContent?.trim() === 'Log out')
|
||||
;(logOutItem as HTMLElement)?.click()
|
||||
await flushPromises()
|
||||
|
||||
@@ -141,8 +157,12 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: true })
|
||||
|
||||
expect(wrapper.text()).not.toContain('marta')
|
||||
expect(findTriggerButton()?.getAttribute('aria-label')).toBe('marta — account menu')
|
||||
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
||||
expect(findTriggerButton()?.getAttribute('aria-label')).toBe(
|
||||
'marta — account menu',
|
||||
)
|
||||
expect(
|
||||
document.body.querySelector('[data-slot="fallback"]')?.textContent,
|
||||
).toBe('MA')
|
||||
|
||||
await openMenu()
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
||||
|
||||
async function mountLogin() {
|
||||
setActivePinia(createPinia())
|
||||
global.fetch = (() => mockResponse(200, { open: false })) as unknown as typeof fetch
|
||||
global.fetch = (() =>
|
||||
mockResponse(200, { open: false })) as unknown as typeof fetch
|
||||
await router.push('/login')
|
||||
const wrapper = mount(LoginView, {
|
||||
attachTo: document.body,
|
||||
@@ -69,7 +70,9 @@ describe('AppLogo renders the brand mark as inline SVG, and theme-switches', ()
|
||||
expect(card.element.contains(svg.element)).toBe(true)
|
||||
|
||||
const html = wrapper.html()
|
||||
expect(html.indexOf('<svg')).toBeGreaterThan(html.indexOf('data-testid="auth-card"'))
|
||||
expect(html.indexOf('<svg')).toBeGreaterThan(
|
||||
html.indexOf('data-testid="auth-card"'),
|
||||
)
|
||||
expect(html.indexOf('<svg')).toBeLessThan(html.indexOf('<h1'))
|
||||
|
||||
let centeringAncestor = svg.element.parentElement
|
||||
@@ -110,7 +113,9 @@ describe("UPageCard's title is the page h1, and its description uses UPageCard's
|
||||
expect(heading.text()).toBe('Log in')
|
||||
expect(heading.attributes('tabindex')).toBe('-1')
|
||||
|
||||
expect(wrapper.text()).toContain('Welcome back. Log in to manage your articles.')
|
||||
expect(wrapper.text()).toContain(
|
||||
'Welcome back. Log in to manage your articles.',
|
||||
)
|
||||
expect(wrapper.findAll('p').length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -175,8 +180,12 @@ describe('The login form is a UForm', () => {
|
||||
const form = wrapper.find('form')
|
||||
expect(form.exists()).toBe(true)
|
||||
expect(form.attributes('id')).toMatch(/^v-/)
|
||||
expect(form.find('input[placeholder="Your username"]').exists()).toBe(true)
|
||||
expect(form.find('input[autocomplete="current-password"]').exists()).toBe(true)
|
||||
expect(form.find('input[placeholder="Your username"]').exists()).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
form.find('input[autocomplete="current-password"]').exists(),
|
||||
).toBe(true)
|
||||
expect(form.find('button[type="submit"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -185,14 +194,20 @@ describe('"Forgot password?" renders in the Password field\'s hint slot', () =>
|
||||
it('renders the link through the hint slot, and the label reads only Password', async () => {
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
||||
const fieldRoot = passwordInput.element.closest('[data-slot="root"][data-orientation]')
|
||||
const passwordInput = wrapper.find(
|
||||
'input[autocomplete="current-password"]',
|
||||
)
|
||||
const fieldRoot = passwordInput.element.closest(
|
||||
'[data-slot="root"][data-orientation]',
|
||||
)
|
||||
expect(fieldRoot).toBeTruthy()
|
||||
|
||||
const hint = fieldRoot!.querySelector('[data-slot="hint"]')
|
||||
expect(hint).toBeTruthy()
|
||||
expect(hint!.textContent?.trim()).toBe('Forgot password?')
|
||||
expect(hint!.querySelector('a[href="/register/reset/request"]')).toBeTruthy()
|
||||
expect(
|
||||
hint!.querySelector('a[href="/register/reset/request"]'),
|
||||
).toBeTruthy()
|
||||
|
||||
const label = fieldRoot!.querySelector('[data-slot="label"]')
|
||||
expect(label!.textContent?.trim()).toBe('Password')
|
||||
@@ -204,7 +219,9 @@ describe('Inputs are neutral by default and turn to the error edge only when the
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const usernameInput = wrapper.find('input[placeholder="Your username"]')
|
||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
||||
const passwordInput = wrapper.find(
|
||||
'input[autocomplete="current-password"]',
|
||||
)
|
||||
expect(usernameInput.classes()).toContain('ring-accented')
|
||||
expect(usernameInput.classes()).not.toContain('ring-error')
|
||||
expect(passwordInput.classes()).toContain('ring-accented')
|
||||
@@ -215,7 +232,9 @@ describe('Inputs are neutral by default and turn to the error edge only when the
|
||||
await flushPromises()
|
||||
|
||||
const usernameAfter = wrapper.find('input[placeholder="Your username"]')
|
||||
const passwordAfter = wrapper.find('input[autocomplete="current-password"]')
|
||||
const passwordAfter = wrapper.find(
|
||||
'input[autocomplete="current-password"]',
|
||||
)
|
||||
expect(usernameAfter.classes()).toContain('ring-error')
|
||||
expect(usernameAfter.classes()).not.toContain('ring-accented')
|
||||
expect(passwordAfter.classes()).toContain('ring-error')
|
||||
|
||||
@@ -33,7 +33,8 @@ 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
|
||||
global.fetch = (() =>
|
||||
mockResponse(200, { open: false })) as unknown as typeof fetch
|
||||
await router.push(path)
|
||||
const wrapper = mount(LoginView, {
|
||||
attachTo: document.body,
|
||||
@@ -77,7 +78,9 @@ describe('A field error shows the circle-alert icon with no extra focus halo', (
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const fieldRoots = wrapper.findAll('[data-slot="root"][data-orientation]')
|
||||
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"]')
|
||||
|
||||
@@ -94,7 +97,9 @@ describe('A field error shows the circle-alert icon with no extra focus halo', (
|
||||
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')
|
||||
expect(usernameInput.classes()).not.toContain(
|
||||
'focus-visible:ring-primary',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -141,19 +146,31 @@ describe('The alert renders inside the card, under the description', () => {
|
||||
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 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 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('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,
|
||||
)
|
||||
expect(
|
||||
alert
|
||||
.classes()
|
||||
.some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c)),
|
||||
).toBe(true)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user