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 let scrollIntoView: ReturnType 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('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('[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(`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) }) })