789 lines
26 KiB
TypeScript
789 lines
26 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import uiPlugin from '@nuxt/ui/vue-plugin'
|
|
import router from '@/router'
|
|
import { useSessionStore } from '@/stores/session'
|
|
import EndpointCard from '@/components/EndpointCard.vue'
|
|
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
|
import IntegrationView from '../IntegrationView.vue'
|
|
import { ownerAccess } from '@/test/access'
|
|
|
|
const SEARCH_LABEL = 'Search the API reference'
|
|
const FLASH_MS = 1500
|
|
|
|
let currentWrapper: VueWrapper | null = null
|
|
let fetchSpy: ReturnType<typeof vi.fn>
|
|
let scrollIntoView: ReturnType<typeof vi.fn>
|
|
|
|
function stubReducedMotion(reduced: boolean) {
|
|
const base = window.matchMedia
|
|
window.matchMedia = ((query: string) => {
|
|
const media = base(query)
|
|
return query.includes('prefers-reduced-motion') ? { ...media, matches: reduced } : media
|
|
}) as typeof window.matchMedia
|
|
}
|
|
|
|
async function mountIntegration(width = DESKTOP_WIDTH, hash = '') {
|
|
setViewportWidth(width)
|
|
stubReducedMotion(false)
|
|
fetchSpy = vi.fn()
|
|
vi.stubGlobal('fetch', fetchSpy)
|
|
scrollIntoView = vi.fn()
|
|
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
|
configurable: true,
|
|
value: scrollIntoView,
|
|
})
|
|
setActivePinia(createPinia())
|
|
const session = useSessionStore()
|
|
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
|
session.status = 'ready'
|
|
await router.push(`/integration${hash}`)
|
|
const wrapper = mount(IntegrationView, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, uiPlugin] },
|
|
})
|
|
currentWrapper = wrapper
|
|
await flushPromises()
|
|
return wrapper
|
|
}
|
|
|
|
function press(target: EventTarget, key: string, init: KeyboardEventInit = {}) {
|
|
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })
|
|
target.dispatchEvent(event)
|
|
return event
|
|
}
|
|
|
|
function searchInput() {
|
|
return document.body.querySelector<HTMLInputElement>('input[role="combobox"]')
|
|
}
|
|
|
|
function paletteOpen() {
|
|
return searchInput() !== null
|
|
}
|
|
|
|
async function openWithSlash() {
|
|
press(document.body, '/')
|
|
await flushPromises()
|
|
}
|
|
|
|
async function type(text: string) {
|
|
const input = searchInput()!
|
|
input.value = text
|
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
await flushPromises()
|
|
}
|
|
|
|
async function pressInPalette(key: string) {
|
|
press(searchInput()!, key)
|
|
await flushPromises()
|
|
}
|
|
|
|
function options() {
|
|
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
|
}
|
|
|
|
function optionRoutes() {
|
|
return options().map((option) => option.getAttribute('aria-label')!.split('. ')[0])
|
|
}
|
|
|
|
function selectedIndex() {
|
|
return options().findIndex((option) => option.getAttribute('aria-selected') === 'true')
|
|
}
|
|
|
|
function liveRegion() {
|
|
return document.body.querySelector('[role="status"]')!.textContent
|
|
}
|
|
|
|
function groupTitles() {
|
|
return Array.from(document.body.querySelectorAll('[role="group"]')).map((group) => {
|
|
const id = group.getAttribute('aria-labelledby')!
|
|
return document.getElementById(id)!.textContent!.trim()
|
|
})
|
|
}
|
|
|
|
function cardFor(wrapper: VueWrapper, id: string) {
|
|
return wrapper.findAllComponents(EndpointCard).find((item) => item.props('endpoint').id === id)!
|
|
}
|
|
|
|
function openCards(wrapper: VueWrapper) {
|
|
return wrapper
|
|
.findAll('[data-anchor^="ep-"] button[aria-expanded="true"]')
|
|
.map((button) => button.attributes('aria-label'))
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
|
})
|
|
|
|
afterEach(async () => {
|
|
currentWrapper?.unmount()
|
|
currentWrapper = null
|
|
vi.unstubAllGlobals()
|
|
vi.useRealTimers()
|
|
document.body.innerHTML = ''
|
|
window.history.replaceState(null, '', '/')
|
|
await router.push('/login')
|
|
})
|
|
|
|
async function settle() {
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
await flushPromises()
|
|
}
|
|
|
|
describe('Desktop trigger', () => {
|
|
it('shows the field-style button with placeholder, key hint and shortcuts', async () => {
|
|
const wrapper = await mountIntegration()
|
|
|
|
const trigger = wrapper.get(`button[aria-label="${SEARCH_LABEL}"]`)
|
|
expect(trigger.text()).toContain('Search the API reference…')
|
|
expect(trigger.text()).toContain('/')
|
|
expect(trigger.attributes('aria-keyshortcuts')).toBe('/ Control+K Meta+K')
|
|
expect(trigger.classes()).toContain('lg:inline-flex')
|
|
expect(trigger.classes()).toContain('hidden')
|
|
expect(wrapper.find('.sticky button[aria-label="Search the API reference"]').exists()).toBe(
|
|
true,
|
|
)
|
|
})
|
|
|
|
it('is not inside the sticky bar', async () => {
|
|
const wrapper = await mountIntegration()
|
|
|
|
const desktopTriggers = wrapper
|
|
.findAll(`button[aria-label="${SEARCH_LABEL}"]`)
|
|
.filter((button) => button.attributes('aria-keyshortcuts'))
|
|
expect(desktopTriggers).toHaveLength(1)
|
|
expect(desktopTriggers[0]!.element.closest('.sticky')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('Mobile trigger', () => {
|
|
it('sits beside Jump to as an icon-only button', async () => {
|
|
const wrapper = await mountIntegration(MOBILE_WIDTH)
|
|
|
|
const bar = wrapper.get('button[aria-label="Jump to section"]').element.closest('.sticky')!
|
|
const icon = bar.querySelector(`button[aria-label="${SEARCH_LABEL}"]`)!
|
|
expect(icon.textContent?.trim()).toBe('')
|
|
expect(icon.classList.contains('size-11')).toBe(true)
|
|
const desktop = wrapper
|
|
.findAll(`button[aria-label="${SEARCH_LABEL}"]`)
|
|
.find((button) => button.attributes('aria-keyshortcuts'))!
|
|
expect(desktop.classes()).toContain('hidden')
|
|
expect(desktop.classes()).toContain('lg:inline-flex')
|
|
})
|
|
|
|
it('opens the full-screen sheet with a Cancel button that returns focus to the trigger', async () => {
|
|
const wrapper = await mountIntegration(MOBILE_WIDTH)
|
|
const bar = wrapper.get('button[aria-label="Jump to section"]').element.closest('.sticky')!
|
|
const icon = bar.querySelector<HTMLButtonElement>(`button[aria-label="${SEARCH_LABEL}"]`)!
|
|
icon.focus()
|
|
|
|
icon.click()
|
|
await settle()
|
|
expect(paletteOpen()).toBe(true)
|
|
const cancel = Array.from(document.body.querySelectorAll('button')).find(
|
|
(button) => button.textContent?.trim() === 'Cancel',
|
|
)
|
|
expect(cancel).toBeInstanceOf(HTMLButtonElement)
|
|
|
|
cancel!.click()
|
|
await settle()
|
|
expect(paletteOpen()).toBe(false)
|
|
expect(document.activeElement).toBe(icon)
|
|
})
|
|
})
|
|
|
|
describe('Opening the palette with a shortcut', () => {
|
|
it.each([
|
|
['/ on the page body', () => document.body, '/', {}],
|
|
['Ctrl+K on the page body', () => document.body, 'k', { ctrlKey: true }],
|
|
['Cmd+K on the page body', () => document.body, 'k', { metaKey: true }],
|
|
])('%s opens the palette', async (_name, target, key, init) => {
|
|
await mountIntegration()
|
|
|
|
const event = press(target(), key, init)
|
|
await settle()
|
|
|
|
expect(paletteOpen()).toBe(true)
|
|
expect(event.defaultPrevented).toBe(true)
|
|
})
|
|
|
|
it('opens with / from a button and does not type the slash', async () => {
|
|
const wrapper = await mountIntegration()
|
|
const button = wrapper.get('button[aria-expanded]').element as HTMLButtonElement
|
|
button.focus()
|
|
|
|
const event = press(button, '/')
|
|
await settle()
|
|
|
|
expect(paletteOpen()).toBe(true)
|
|
expect(event.defaultPrevented).toBe(true)
|
|
expect(searchInput()!.value).toBe('')
|
|
})
|
|
|
|
it('ignores / in a text input, a textarea and a contenteditable', async () => {
|
|
await mountIntegration()
|
|
const input = document.createElement('input')
|
|
const textarea = document.createElement('textarea')
|
|
const editable = document.createElement('div')
|
|
editable.setAttribute('contenteditable', 'true')
|
|
document.body.append(input, textarea, editable)
|
|
|
|
for (const field of [input, textarea, editable]) {
|
|
const event = press(field, '/')
|
|
await settle()
|
|
expect(event.defaultPrevented).toBe(false)
|
|
expect(paletteOpen()).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('opens with Ctrl+K from inside a text input', async () => {
|
|
await mountIntegration()
|
|
const input = document.createElement('input')
|
|
document.body.append(input)
|
|
|
|
const event = press(input, 'k', { ctrlKey: true })
|
|
await settle()
|
|
|
|
expect(paletteOpen()).toBe(true)
|
|
expect(event.defaultPrevented).toBe(true)
|
|
})
|
|
|
|
it('removes the shortcuts when the user leaves the page', async () => {
|
|
const wrapper = await mountIntegration()
|
|
wrapper.unmount()
|
|
currentWrapper = null
|
|
|
|
const slash = press(document.body, '/')
|
|
const chord = press(document.body, 'k', { ctrlKey: true })
|
|
await settle()
|
|
|
|
expect(paletteOpen()).toBe(false)
|
|
expect(slash.defaultPrevented).toBe(false)
|
|
expect(chord.defaultPrevented).toBe(false)
|
|
})
|
|
|
|
it('leaves Ctrl+S alone', async () => {
|
|
await mountIntegration()
|
|
|
|
const event = press(document.body, 's', { ctrlKey: true })
|
|
await settle()
|
|
|
|
expect(event.defaultPrevented).toBe(false)
|
|
expect(paletteOpen()).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('Opening focuses the input and closing restores focus', () => {
|
|
it('focuses the input and returns focus to the desktop trigger on Escape', async () => {
|
|
const wrapper = await mountIntegration()
|
|
const trigger = wrapper.get(`button[aria-keyshortcuts]`).element as HTMLButtonElement
|
|
trigger.focus()
|
|
|
|
trigger.click()
|
|
await settle()
|
|
expect(document.activeElement).toBe(searchInput())
|
|
|
|
await pressInPalette('Escape')
|
|
expect(paletteOpen()).toBe(false)
|
|
expect(document.activeElement).toBe(trigger)
|
|
})
|
|
|
|
it('returns focus to the element that had it when opened with the keyboard', async () => {
|
|
const wrapper = await mountIntegration()
|
|
const other = wrapper.get('button[aria-expanded]').element as HTMLButtonElement
|
|
other.focus()
|
|
|
|
press(other, 'k', { ctrlKey: true })
|
|
await settle()
|
|
await pressInPalette('Escape')
|
|
|
|
expect(document.activeElement).toBe(other)
|
|
})
|
|
})
|
|
|
|
describe('8b Open, empty query', () => {
|
|
it('shows the five suggested rows, the first selected, and 5 suggestions', async () => {
|
|
await mountIntegration()
|
|
await openWithSlash()
|
|
|
|
expect(searchInput()!.getAttribute('placeholder')).toBe('Search the API reference…')
|
|
expect(groupTitles()).toEqual(['Suggested'])
|
|
expect(options().map((option) => option.getAttribute('aria-label'))).toEqual([
|
|
expect.stringMatching(/^Section Authentication\. Public access and API keys$/),
|
|
expect.stringMatching(
|
|
/^POST \/v1\/media\/\. Upload an image \(multipart\)\.\. Verified user$/,
|
|
),
|
|
expect.stringMatching(/^Section Errors\. Status codes and messages$/),
|
|
expect.stringMatching(/^GET \/v1\/article\/\. .*\. Public$/),
|
|
expect.stringMatching(/^PATCH \/v1\/user\/me\. Update my profile\.\. Verified user$/),
|
|
])
|
|
expect(selectedIndex()).toBe(0)
|
|
expect(document.body.textContent).toContain('5 suggestions')
|
|
expect(liveRegion()).toBe('5 suggestions')
|
|
expect(document.body.textContent).toContain('Move')
|
|
expect(document.body.textContent).toContain('Open')
|
|
expect(document.body.textContent).toContain('Close')
|
|
})
|
|
|
|
it('stores nothing as a recent search', async () => {
|
|
await mountIntegration()
|
|
const stored = JSON.stringify({ ...localStorage })
|
|
await openWithSlash()
|
|
await type('media')
|
|
|
|
expect(JSON.stringify({ ...localStorage })).toBe(stored)
|
|
expect(sessionStorage.length).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('8c Typing media', () => {
|
|
it('lists endpoints then the Media section, with highlights and the count', async () => {
|
|
await mountIntegration()
|
|
await openWithSlash()
|
|
await type('media')
|
|
|
|
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
|
|
const rows = optionRoutes()
|
|
expect(rows.slice(0, 6)).toEqual([
|
|
'GET /v1/builtin-type/',
|
|
'GET /v1/media/',
|
|
'GET /v1/media/{id}',
|
|
'POST /v1/media/',
|
|
'PATCH /v1/media/{id}',
|
|
'DELETE /v1/media/{id}',
|
|
])
|
|
expect(rows.at(-1)).toBe('Section Media')
|
|
expect(options().at(-1)!.textContent).toContain('11 routes')
|
|
expect(document.body.textContent).toContain('17 results')
|
|
expect(liveRegion()).toBe('17 results, showing 7')
|
|
expect(document.body.textContent).toContain(
|
|
'10 more endpoints. Keep typing to narrow the list.',
|
|
)
|
|
const marks = Array.from(document.body.querySelectorAll('[role="option"] mark'))
|
|
expect(marks.length).toBeGreaterThan(0)
|
|
expect(marks.every((mark) => mark.textContent!.toLowerCase() === 'media')).toBe(true)
|
|
expect(marks[0]!.className).toContain('--rc-sand')
|
|
expect(marks[0]!.className).toContain('--rc-on-sand')
|
|
})
|
|
})
|
|
|
|
describe('8d More endpoints than the cap', () => {
|
|
it('lists six rows with an Error line, the Errors section and the cap line', async () => {
|
|
await mountIntegration()
|
|
await openWithSlash()
|
|
await type('404')
|
|
|
|
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
|
|
const endpointRows = options().slice(0, -1)
|
|
expect(endpointRows).toHaveLength(6)
|
|
expect(endpointRows.every((row) => row.textContent!.includes('Error'))).toBe(true)
|
|
expect(endpointRows[0]!.textContent).toMatch(/Error\s*404 /)
|
|
expect(options().at(-1)!.getAttribute('aria-label')).toBe(
|
|
'Section Errors. Status codes and messages',
|
|
)
|
|
expect(document.body.textContent).toContain('25 results')
|
|
expect(document.body.textContent).toContain(
|
|
'18 more endpoints. Keep typing to narrow the list.',
|
|
)
|
|
expect(liveRegion()).toBe('25 results, showing 7')
|
|
})
|
|
})
|
|
|
|
describe('8e Match on a body field', () => {
|
|
it('lists only POST /v1/media/merge with the body field line', async () => {
|
|
await mountIntegration()
|
|
await openWithSlash()
|
|
await type('keep_id')
|
|
|
|
expect(optionRoutes()).toEqual(['POST /v1/media/merge'])
|
|
const row = options()[0]!
|
|
expect(row.textContent).toContain('Merge identical copies into one image.')
|
|
expect(row.textContent).toContain('Verified user')
|
|
expect(row.textContent).toContain('Body field')
|
|
expect(row.querySelector('mark')!.textContent).toBe('keep_id')
|
|
expect(document.body.textContent).toContain('1 result')
|
|
expect(liveRegion()).toBe('1 result')
|
|
})
|
|
|
|
it('labels a query parameter', async () => {
|
|
await mountIntegration()
|
|
await openWithSlash()
|
|
await type('hash')
|
|
|
|
const row = options().find((item) =>
|
|
item.getAttribute('aria-label')!.startsWith('GET /v1/media/. '),
|
|
)!
|
|
expect(row.textContent).toContain('Query parameter')
|
|
})
|
|
|
|
it('shows no second line for a path match', async () => {
|
|
await mountIntegration()
|
|
await openWithSlash()
|
|
await type('merge')
|
|
|
|
expect(options()[0]!.textContent).not.toContain('Body field')
|
|
})
|
|
})
|
|
|
|
describe('8f Session-only route', () => {
|
|
it('styles the route as session only and lists the Session only section', async () => {
|
|
await mountIntegration()
|
|
await openWithSlash()
|
|
await type('login')
|
|
|
|
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
|
|
const [route, section] = options()
|
|
expect(route!.getAttribute('aria-label')).toBe('POST /v1/auth/login. Log in. Session only')
|
|
expect(route!.textContent).toContain('Session only')
|
|
expect(route!.querySelector('[data-slot="leadingIcon"]')).not.toBeNull()
|
|
const path = route!.querySelector('mark')!.parentElement!
|
|
expect(path.classList.contains('font-normal')).toBe(true)
|
|
expect(route!.innerHTML).toContain('border-dashed')
|
|
expect(section!.getAttribute('aria-label')).toBe(
|
|
'Section Session only. 40 routes, not available to API keys',
|
|
)
|
|
expect(section!.querySelector('svg')).not.toBeNull()
|
|
expect(document.body.textContent).toContain('2 results')
|
|
})
|
|
})
|
|
|
|
describe('8g No results', () => {
|
|
it('shows the empty copy, 0 results and No results', async () => {
|
|
await mountIntegration()
|
|
await openWithSlash()
|
|
await type('xyz')
|
|
|
|
expect(options()).toHaveLength(0)
|
|
expect(document.body.textContent).toContain(
|
|
'No match for "xyz". Try a path, a method or a status code like 404.',
|
|
)
|
|
expect(document.body.textContent).toContain('0 results')
|
|
expect(liveRegion()).toBe('No results')
|
|
})
|
|
|
|
it('does not match a misspelled term', async () => {
|
|
await mountIntegration()
|
|
await openWithSlash()
|
|
await type('mdia')
|
|
|
|
expect(options()).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('Keyboard navigation', () => {
|
|
async function showSeven() {
|
|
await mountIntegration()
|
|
await openWithSlash()
|
|
await type('media')
|
|
return options().length
|
|
}
|
|
|
|
it('moves the selection with the arrows and wraps', async () => {
|
|
const count = await showSeven()
|
|
expect(count).toBe(7)
|
|
|
|
await pressInPalette('ArrowDown')
|
|
expect(selectedIndex()).toBe(1)
|
|
expect(document.activeElement).toBe(searchInput())
|
|
|
|
await pressInPalette('ArrowUp')
|
|
await pressInPalette('ArrowUp')
|
|
expect(selectedIndex()).toBe(6)
|
|
|
|
await pressInPalette('ArrowDown')
|
|
expect(selectedIndex()).toBe(0)
|
|
})
|
|
|
|
it('jumps to the first and last row with Home and End', async () => {
|
|
await showSeven()
|
|
await pressInPalette('ArrowDown')
|
|
await pressInPalette('ArrowDown')
|
|
|
|
await pressInPalette('End')
|
|
expect(selectedIndex()).toBe(6)
|
|
await pressInPalette('Home')
|
|
expect(selectedIndex()).toBe(0)
|
|
})
|
|
|
|
it('resets the selection to the first row when typing', async () => {
|
|
await showSeven()
|
|
await pressInPalette('ArrowDown')
|
|
await pressInPalette('ArrowDown')
|
|
expect(selectedIndex()).toBe(2)
|
|
|
|
await type('medi')
|
|
|
|
expect(selectedIndex()).toBe(0)
|
|
})
|
|
|
|
it('follows the selection with aria-activedescendant', async () => {
|
|
await showSeven()
|
|
await pressInPalette('ArrowDown')
|
|
|
|
expect(searchInput()!.getAttribute('aria-activedescendant')).toBe(options()[1]!.id)
|
|
})
|
|
|
|
it('closes on Escape without scrolling or changing the hash', async () => {
|
|
await showSeven()
|
|
window.history.replaceState(null, '', '/integration#media')
|
|
|
|
await pressInPalette('Escape')
|
|
|
|
expect(paletteOpen()).toBe(false)
|
|
expect(scrollIntoView).not.toHaveBeenCalled()
|
|
expect(window.location.hash).toBe('#media')
|
|
})
|
|
})
|
|
|
|
describe('Roles and live region', () => {
|
|
it('exposes the combobox, listbox, groups and options', async () => {
|
|
await mountIntegration()
|
|
await openWithSlash()
|
|
await type('media')
|
|
|
|
const input = searchInput()!
|
|
const list = document.body.querySelector('[role="listbox"]')!
|
|
expect(input.getAttribute('aria-expanded')).toBe('true')
|
|
expect(input.getAttribute('aria-controls')).toBe(list.id)
|
|
expect(input.getAttribute('aria-autocomplete')).toBe('list')
|
|
expect(input.getAttribute('aria-activedescendant')).toBe(options()[0]!.id)
|
|
expect(document.body.querySelectorAll('[role="group"]')).toHaveLength(2)
|
|
expect(options()[0]!.getAttribute('aria-selected')).toBe('true')
|
|
expect(options()[1]!.getAttribute('aria-selected')).toBe('false')
|
|
expect(options()[0]!.getAttribute('aria-label')).toMatch(/^[A-Z]+ \/v1\/.*\. .*\. .+$/)
|
|
expect(document.body.querySelector('[role="status"]')!.getAttribute('aria-live')).toBe(
|
|
'polite',
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('9f to 9i Content types and entries', () => {
|
|
async function searchFor(query: string) {
|
|
const wrapper = await mountIntegration()
|
|
await openWithSlash()
|
|
await type(query)
|
|
return wrapper
|
|
}
|
|
|
|
it('9f lists the entry routes, matches "entries" and caps with the plural line', async () => {
|
|
await searchFor('entry')
|
|
|
|
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
|
|
expect(optionRoutes()).toEqual([
|
|
'GET /v1/content-type/{key}/entry',
|
|
'POST /v1/content-type/{key}/entry',
|
|
'GET /v1/content-type/{key}/entry/{id}',
|
|
'PUT /v1/content-type/{key}/entry/{id}',
|
|
'DELETE /v1/content-type/{key}/entry/{id}',
|
|
'PUT /v1/content-type/{key}',
|
|
'Section Entries',
|
|
])
|
|
expect(document.body.textContent).toContain('8 results')
|
|
expect(liveRegion()).toBe('8 results, showing 7')
|
|
expect(document.body.textContent).toContain(
|
|
'1 more endpoint. Keep typing to narrow the list.',
|
|
)
|
|
expect(document.body.textContent).not.toContain('1 more endpoints')
|
|
})
|
|
|
|
it('9g labels the capacity key as a Body field on POST and PUT entry', async () => {
|
|
await searchFor('capacity')
|
|
|
|
expect(optionRoutes()).toEqual([
|
|
'POST /v1/content-type/{key}/entry',
|
|
'PUT /v1/content-type/{key}/entry/{id}',
|
|
])
|
|
for (const row of options()) {
|
|
expect(row.textContent).toContain('Body field')
|
|
expect(row.querySelector('mark')!.textContent).toBe('capacity')
|
|
}
|
|
expect(document.body.textContent).toContain('2 results')
|
|
expect(liveRegion()).toBe('2 results')
|
|
})
|
|
|
|
it('9h labels schema_incompatible as an Error on PUT /v1/content-type/{key}', async () => {
|
|
await searchFor('schema_incompatible')
|
|
|
|
expect(optionRoutes()).toEqual(['PUT /v1/content-type/{key}'])
|
|
expect(options()[0]!.textContent).toContain('Error')
|
|
expect(options()[0]!.textContent).toContain('409 schema_incompatible')
|
|
expect(liveRegion()).toBe('1 result')
|
|
})
|
|
|
|
it('9i lists Content types and Entries in the Sections group', async () => {
|
|
await searchFor('content type')
|
|
|
|
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
|
|
expect(optionRoutes().slice(-2)).toEqual(['Section Content types', 'Section Entries'])
|
|
expect(options()).toHaveLength(8)
|
|
expect(document.body.textContent).toContain('12 results')
|
|
expect(liveRegion()).toBe('12 results, showing 8')
|
|
})
|
|
|
|
it('selects PUT /v1/content-type/{key}, expands it, flashes it and reads Content types', async () => {
|
|
const wrapper = await searchFor('schema_incompatible')
|
|
await pressInPalette('Enter')
|
|
await wrapper.vm.$nextTick()
|
|
await settle()
|
|
|
|
const button = cardFor(wrapper, 'ct-update').get('button[aria-expanded]')
|
|
expect(paletteOpen()).toBe(false)
|
|
expect(button.attributes('aria-expanded')).toBe('true')
|
|
expect(document.activeElement).toBe(button.element)
|
|
expect(button.classes()).toContain('endpoint-flash')
|
|
expect(window.location.hash).toBe('#ep-ct-update')
|
|
const index = wrapper.get('nav[aria-label="On this page"]')
|
|
expect(index.get('button[aria-current="true"]').text()).toBe('Content types')
|
|
|
|
await vi.advanceTimersByTimeAsync(FLASH_MS)
|
|
await flushPromises()
|
|
|
|
expect(button.classes()).not.toContain('endpoint-flash')
|
|
})
|
|
})
|
|
|
|
describe('8h Selecting an endpoint', () => {
|
|
async function selectMerge(wrapper: VueWrapper, how: 'enter' | 'click') {
|
|
await openWithSlash()
|
|
await type('keep_id')
|
|
if (how === 'enter') {
|
|
await pressInPalette('Enter')
|
|
} else {
|
|
options()[0]!.click()
|
|
await settle()
|
|
}
|
|
await wrapper.vm.$nextTick()
|
|
await settle()
|
|
}
|
|
|
|
it.each(['enter', 'click'] as const)(
|
|
'closes, scrolls, expands, focuses, flashes and writes the hash with %s',
|
|
async (how) => {
|
|
const wrapper = await mountIntegration()
|
|
const historyLength = window.history.length
|
|
const card = cardFor(wrapper, 'med-merge')
|
|
const replaceState = vi.spyOn(window.history, 'replaceState')
|
|
|
|
await selectMerge(wrapper, how)
|
|
|
|
expect(paletteOpen()).toBe(false)
|
|
expect(scrollIntoView.mock.contexts).toContain(
|
|
wrapper.get('[data-anchor="ep-med-merge"]').element,
|
|
)
|
|
const button = card.get('button[aria-expanded]')
|
|
expect(button.attributes('aria-expanded')).toBe('true')
|
|
expect(document.activeElement).toBe(button.element)
|
|
expect(button.classes()).toContain('endpoint-flash')
|
|
expect(window.location.hash).toBe('#ep-med-merge')
|
|
expect(replaceState).toHaveBeenCalled()
|
|
expect(window.history.length).toBe(historyLength)
|
|
expect(openCards(wrapper)).toHaveLength(1)
|
|
const index = wrapper.get('nav[aria-label="On this page"]')
|
|
expect(index.get('button[aria-current="true"]').text()).toBe('Media')
|
|
|
|
await vi.advanceTimersByTimeAsync(FLASH_MS)
|
|
await flushPromises()
|
|
|
|
expect(button.classes()).not.toContain('endpoint-flash')
|
|
expect(button.attributes('aria-expanded')).toBe('true')
|
|
},
|
|
)
|
|
|
|
it('keeps the other cards as they were', async () => {
|
|
const wrapper = await mountIntegration()
|
|
await wrapper.get('button[aria-label^="GET /v1/media/. "]').trigger('click')
|
|
await flushPromises()
|
|
|
|
await selectMerge(wrapper, 'enter')
|
|
|
|
expect(openCards(wrapper)).toHaveLength(2)
|
|
})
|
|
|
|
it('uses a static ring with reduced motion', async () => {
|
|
const wrapper = await mountIntegration()
|
|
stubReducedMotion(true)
|
|
|
|
await selectMerge(wrapper, 'enter')
|
|
|
|
const button = cardFor(wrapper, 'med-merge').get('button[aria-expanded]')
|
|
expect(button.classes()).toContain('endpoint-flash-static')
|
|
expect(button.classes()).not.toContain('endpoint-flash')
|
|
})
|
|
})
|
|
|
|
describe('Selecting a section', () => {
|
|
it('focuses the heading, writes the hash and expands nothing', async () => {
|
|
const wrapper = await mountIntegration()
|
|
await openWithSlash()
|
|
await type('errors')
|
|
await pressInPalette('Enter')
|
|
await settle()
|
|
|
|
expect(paletteOpen()).toBe(false)
|
|
expect(scrollIntoView.mock.contexts).toContain(
|
|
wrapper.get('[data-anchor="errors"]').element,
|
|
)
|
|
expect(document.activeElement).toBe(wrapper.get('#h-errors').element)
|
|
expect(window.location.hash).toBe('#errors')
|
|
expect(openCards(wrapper)).toHaveLength(0)
|
|
expect(wrapper.find('.endpoint-flash').exists()).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('Selecting a session-only route', () => {
|
|
it('goes to the Session only heading without expanding a card', async () => {
|
|
const wrapper = await mountIntegration()
|
|
await openWithSlash()
|
|
await type('login')
|
|
await pressInPalette('Enter')
|
|
await settle()
|
|
|
|
expect(document.activeElement).toBe(wrapper.get('#h-session').element)
|
|
expect(openCards(wrapper)).toHaveLength(0)
|
|
expect(wrapper.find('.endpoint-flash').exists()).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('Shared link', () => {
|
|
it('scrolls to, expands, focuses and flashes the card without opening the palette', async () => {
|
|
const wrapper = await mountIntegration(DESKTOP_WIDTH, '#ep-med-merge')
|
|
await settle()
|
|
|
|
const button = cardFor(wrapper, 'med-merge').get('button[aria-expanded]')
|
|
expect(paletteOpen()).toBe(false)
|
|
expect(button.attributes('aria-expanded')).toBe('true')
|
|
expect(document.activeElement).toBe(button.element)
|
|
expect(button.classes()).toContain('endpoint-flash')
|
|
expect(scrollIntoView.mock.contexts).toContain(
|
|
wrapper.get('[data-anchor="ep-med-merge"]').element,
|
|
)
|
|
})
|
|
|
|
it('does nothing for an unknown id', async () => {
|
|
const wrapper = await mountIntegration(DESKTOP_WIDTH, '#ep-nope')
|
|
await settle()
|
|
|
|
expect(openCards(wrapper)).toHaveLength(0)
|
|
expect(paletteOpen()).toBe(false)
|
|
expect(wrapper.find('.endpoint-flash').exists()).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('The page is never filtered', () => {
|
|
it('keeps every section and card in the page while a query is typed', async () => {
|
|
const wrapper = await mountIntegration()
|
|
const sections = wrapper.findAll('[data-anchor]:not([data-anchor^="ep-"])').length
|
|
const cards = wrapper.findAllComponents(EndpointCard).length
|
|
|
|
const calls = fetchSpy.mock.calls.length
|
|
|
|
await openWithSlash()
|
|
await type('keep_id')
|
|
|
|
expect(wrapper.findAll('[data-anchor]:not([data-anchor^="ep-"])')).toHaveLength(sections)
|
|
expect(wrapper.findAllComponents(EndpointCard)).toHaveLength(cards)
|
|
expect(fetchSpy.mock.calls.length).toBe(calls)
|
|
})
|
|
})
|