Files
summercms/admin/tests/smoke/shortcuts.smoke.test.ts
Jakub Zych 058738efdf test(quick-261022-k9k): keyboard shortcut coverage
- hotkey core: combos, AltGr and layouts, labels, aria-keyshortcuts, editable
  targets, modal root, refcounted install, disabled and repeat
- smoke: record, create, Create and new (new session key), Esc, modal scope,
  settings Save and close, My account, list search and the help dialog
- ShortcutHelpDialog rows, keys and close button
2026-10-22 21:07:40 +02:00

429 lines
17 KiB
TypeScript

// Keyboard shortcuts on the whole app (mounted with attachTo, since the
// shortcut listener reads the document): save keys on record forms, settings
// forms and My account, Create and new, Esc, the list search slash, the help
// dialog and modal forms taking the save keys from the page underneath.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { SESSION_HEADER } from '../../src/app/sessionKey'
import { setBundle } from '../../src/app/i18n'
import { clearUser } from '../../src/state/useAuth'
import { setNavigation } from '../../src/state/useNavigation'
import { loadSettings, setSettings } from '../../src/state/useSettings'
import { clearToasts } from '../../src/state/useToasts'
import lang from '../fixtures/lang.json'
import formSchema from '../fixtures/widgets.form-schema.json'
import record from '../fixtures/widgets.record.json'
import listSchema from '../fixtures/widgets.list-schema.json'
import listRows from '../fixtures/widgets.list.json'
import relationSchema from '../fixtures/widgets.relation-schema.json'
import linked from '../fixtures/widgets.relation-linked.json'
import candidates from '../fixtures/widgets.relation-candidates.json'
import settings from '../fixtures/settings.json'
import { API, mockApi, mountApp, press, profile, requestsTo, wait, type Reply, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
const RECORD = `${BASE}/1`
const LINKED = `${RECORD}/relations/members`
const SETTING = `${API}/settings/search`
const ACCOUNT = `${API}/account`
const formRoutes: Record<string, Route> = {
[`GET ${BASE}/schema/form`]: { body: formSchema },
[`GET ${RECORD}`]: { body: record },
[`PUT ${RECORD}`]: { body: record },
[`POST ${BASE}`]: { body: record },
[`GET ${BASE}/schema/list`]: { body: listSchema },
[`GET ${BASE}`]: { body: listRows },
}
beforeEach(() => {
clearUser()
setNavigation([])
setSettings([])
clearToasts()
setBundle(lang.data, 'pl')
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Linux x86_64')
})
afterEach(() => {
document.body.innerHTML = ''
})
// Registered after the cleanup above, so it runs first (hooks run as a
// stack): the app unmounts, with its listeners and portals, before the body
// is cleared.
enableAutoUnmount(afterEach)
const ctrl = (key: string, init: KeyboardEventInit = {}) => press(document.body, key, { ctrlKey: true, ...init })
async function settle(): Promise<void> {
await flushPromises()
await wait(0)
await flushPromises()
}
function confirmDialog(): HTMLElement | null {
return document.body.querySelector('[data-confirm-dialog]')
}
function helpRows(): string[] {
return [...document.body.querySelectorAll<HTMLElement>('[data-shortcut-help] [data-shortcut]')].map(
(row) => row.dataset.shortcut ?? '',
)
}
describe('record form save shortcuts', () => {
it('saves an update form on Ctrl+S and Cmd+S without the browser Save page', async () => {
const { calls } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
const event = ctrl('s')
expect(event.defaultPrevented).toBe(true)
await flushPromises()
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
const cmd = press(document.body, 's', { metaKey: true })
expect(cmd.defaultPrevented).toBe(true)
await flushPromises()
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(2)
})
it('shows the shortcuts in the save button tooltips', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
const save = wrapper.get('[data-action="save"]')
expect(save.attributes('title')).toBe('Zapisz (Ctrl+S)')
expect(save.attributes('aria-keyshortcuts')).toBe('Control+S Meta+S')
expect(wrapper.get('[data-action="save-close"]').attributes('title')).toBe('Zapisz i zamknij (Ctrl+Enter)')
expect(wrapper.get('[data-action="cancel"]').attributes('aria-keyshortcuts')).toBe('Escape')
})
it('saves on Ctrl+S while typing in a text input or a textarea', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
expect(press(wrapper.get('#field-name').element, 's', { ctrlKey: true }).defaultPrevented).toBe(true)
await flushPromises()
expect(press(wrapper.get('#field-notes').element, 's', { ctrlKey: true }).defaultPrevented).toBe(true)
await flushPromises()
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(2)
})
it('sends nothing while the record is still loading', async () => {
let release: (reply: Reply) => void = () => undefined
const pending = new Promise<Reply>((resolve) => {
release = resolve
})
const { calls } = await mountApp(
'/acme/demo/widgets/1',
{ ...formRoutes, [`GET ${RECORD}`]: () => pending },
{ attach: true },
)
expect(ctrl('s').defaultPrevented).toBe(true)
ctrl('Enter')
await flushPromises()
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(0)
release({ body: record })
await flushPromises()
})
it('saves and closes an update form on Ctrl+Enter', async () => {
const { calls, router } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
expect(ctrl('Enter').defaultPrevented).toBe(true)
await settle()
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
})
it('creates on Ctrl+S and follows create.redirect', async () => {
const { calls, router } = await mountApp('/acme/demo/widgets/create', formRoutes, { attach: true })
ctrl('s')
await settle()
expect(requestsTo(calls, 'POST', BASE)).toHaveLength(1)
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/1')
})
it('creates and closes on Ctrl+Enter and follows create.redirectClose', async () => {
const { calls, router } = await mountApp('/acme/demo/widgets/create', formRoutes, { attach: true })
ctrl('Enter')
await settle()
expect(requestsTo(calls, 'POST', BASE)).toHaveLength(1)
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
})
it('shows Create and new on the create form only', async () => {
const create = await mountApp('/acme/demo/widgets/create', formRoutes, { attach: true })
const button = create.wrapper.get('[data-action="save-new"]')
expect(button.text()).toBe('Utwórz i nowy')
expect(button.attributes('title')).toBe('Utwórz i nowy (Ctrl+Shift+S)')
expect(button.attributes('aria-keyshortcuts')).toBe('Control+Shift+S Meta+Shift+S')
create.wrapper.unmount()
document.body.innerHTML = ''
const update = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
expect(update.wrapper.find('[data-action="save-new"]').exists()).toBe(false)
})
it.each([
['the button', (wrapper: VueWrapper) => (wrapper.get('[data-action="save-new"]').element as HTMLElement).click()],
['Ctrl+Shift+S', () => ctrl('S', { shiftKey: true })],
])('Create and new through %s saves and opens an empty create form with a new session', async (_name, trigger) => {
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets/create', formRoutes, { attach: true })
await wrapper.get('#field-name').setValue('Fresh widget')
trigger(wrapper)
await settle()
const [first] = requestsTo(calls, 'POST', BASE)
expect(first).toBeDefined()
expect(((await first!.clone().json()) as { name: string }).name).toBe('Fresh widget')
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/create')
expect((wrapper.get('#field-name').element as HTMLInputElement).value).toBe('')
ctrl('s')
await settle()
const posts = requestsTo(calls, 'POST', BASE)
expect(posts).toHaveLength(2)
const firstKey = posts[0]!.headers.get(SESSION_HEADER)
const secondKey = posts[1]!.headers.get(SESSION_HEADER)
expect(firstKey).toBeTruthy()
expect(secondKey).toBeTruthy()
expect(secondKey).not.toBe(firstKey)
})
})
describe('Esc on a record form', () => {
it('leaves a clean form for its back path', async () => {
const { router } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
expect(press(document.body, 'Escape').defaultPrevented).toBe(true)
await settle()
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
})
it('asks before leaving a dirty form, and Esc on the question closes only the question', async () => {
const { wrapper, router } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
await wrapper.get('#field-name').setValue('Changed')
press(document.body, 'Escape')
await settle()
expect(confirmDialog()).not.toBeNull()
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/1')
press(document.body, 'Escape')
await settle()
expect(confirmDialog()).toBeNull()
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/1')
})
it('does nothing in an input or after a component handled the key', async () => {
const { wrapper, router } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
expect(press(wrapper.get('#field-name').element, 'Escape').defaultPrevented).toBe(false)
await settle()
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/1')
const handler = document.createElement('div')
handler.addEventListener('keydown', (event) => event.preventDefault())
document.body.appendChild(handler)
press(handler, 'Escape')
await settle()
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/1')
})
})
describe('modal forms', () => {
const relationRoutes: Record<string, Route> = {
...formRoutes,
[`GET ${BASE}/schema/relation/members`]: { body: relationSchema },
[`GET ${LINKED}`]: { body: linked.all },
[`GET ${LINKED}/candidates`]: { body: candidates.page1 },
[`POST ${LINKED}/link`]: async (request) => ({
body: { data: { linked: ((await request.json()) as { ids: number[] }).ids.length }, meta: {} },
}),
}
async function openPicker(wrapper: VueWrapper): Promise<HTMLElement> {
const tab = wrapper.findAll('[role="tab"]').find((item) => item.text() === 'Members')
await tab!.trigger('click')
await flushPromises()
;(wrapper.get('[data-relation-manager] [data-action="link"]').element as HTMLElement).click()
await settle()
return document.body.querySelector<HTMLElement>('[data-relation-picker]')!
}
it('submits the picker on Ctrl+S and never saves the record underneath', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', relationRoutes, { attach: true })
const picker = await openPicker(wrapper)
const confirm = picker.querySelector('[data-action="confirm-link"]')!
expect(confirm.getAttribute('aria-keyshortcuts')).toBe('Control+S Meta+S Control+Enter Meta+Enter')
;(picker.querySelector('[role="option"]') as HTMLElement).click()
await flushPromises()
expect(ctrl('s').defaultPrevented).toBe(true)
await settle()
expect(requestsTo(calls, 'POST', `${LINKED}/link`)).toHaveLength(1)
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(0)
})
it('sends nothing while the picker has no selection', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', relationRoutes, { attach: true })
await openPicker(wrapper)
expect(ctrl('s').defaultPrevented).toBe(true)
ctrl('Enter')
await settle()
expect(requestsTo(calls, 'POST', `${LINKED}/link`)).toHaveLength(0)
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(0)
})
})
describe('settings forms and My account', () => {
function settingsRoutes(put?: Route): Record<string, Route> {
return {
[`GET ${API}/settings`]: { body: settings.list },
[`GET ${SETTING}/schema`]: { body: settings.schema },
[`GET ${SETTING}`]: { body: settings.values },
[`PUT ${SETTING}`]: put ?? { body: { data: settings.values.data, meta: {} } },
}
}
async function openSettings(put?: Route) {
mockApi(settingsRoutes(put))
await loadSettings()
return mountApp('/settings/search', settingsRoutes(put), { attach: true })
}
it('saves a settings form on Ctrl+S and stays', async () => {
const { calls, router } = await openSettings()
expect(ctrl('s').defaultPrevented).toBe(true)
await settle()
expect(requestsTo(calls, 'PUT', SETTING)).toHaveLength(1)
expect(router.currentRoute.value.path).toBe('/settings/search')
})
it.each([
['Ctrl+Enter', () => ctrl('Enter')],
['the button', () => (document.body.querySelector('[data-action="save-close"]') as HTMLElement).click()],
])('saves and closes a settings form through %s', async (_name, trigger) => {
const { wrapper, calls, router } = await openSettings()
const button = wrapper.get('[data-action="save-close"]')
expect(button.text()).toBe('Zapisz i zamknij')
expect(button.attributes('title')).toBe('Zapisz i zamknij (Ctrl+Enter)')
trigger()
await settle()
expect(requestsTo(calls, 'PUT', SETTING)).toHaveLength(1)
expect(router.currentRoute.value.path).toBe('/settings')
})
it('stays on the settings form when Save and close fails validation', async () => {
const { calls, router } = await openSettings({
status: 422,
body: { error: { code: 'validation_failed', message: 'Validation failed', details: { engine_host: ['Required'] } } },
})
ctrl('Enter')
await settle()
expect(requestsTo(calls, 'PUT', SETTING)).toHaveLength(1)
expect(router.currentRoute.value.path).toBe('/settings/search')
})
it('does not leave a settings form on Esc', async () => {
const { router } = await openSettings()
press(document.body, 'Escape')
await settle()
expect(router.currentRoute.value.path).toBe('/settings/search')
})
it('saves My account on Ctrl+S and ignores Esc', async () => {
const { calls, router } = await mountApp(
'/account',
{
[`GET ${API}/account/tokens`]: { body: { data: [], meta: {} } },
[`PUT ${ACCOUNT}`]: { body: { data: profile.data, meta: {} } },
},
{ attach: true },
)
expect(ctrl('s').defaultPrevented).toBe(true)
await settle()
expect(requestsTo(calls, 'PUT', ACCOUNT)).toHaveLength(1)
press(document.body, 'Escape')
await settle()
expect(router.currentRoute.value.path).toBe('/account')
})
})
describe('list search and the help dialog', () => {
it('focuses the list search on / without typing it', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets', formRoutes, { attach: true })
const search = wrapper.get('input[data-list-search]').element
expect(press(document.body, '/').defaultPrevented).toBe(true)
expect(document.activeElement).toBe(search)
const other = document.createElement('input')
document.body.appendChild(other)
other.focus()
expect(press(other, '/').defaultPrevented).toBe(false)
expect(document.activeElement).toBe(other)
})
it('has no search target on a list without search', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets',
{ ...formRoutes, [`GET ${BASE}/schema/list`]: { body: { ...listSchema, data: { ...listSchema.data, showSearch: false } } } },
{ attach: true },
)
expect(wrapper.find('input[data-list-search]').exists()).toBe(false)
expect(press(document.body, '/').defaultPrevented).toBe(false)
})
it('lists the shortcuts of a list', async () => {
await mountApp('/acme/demo/widgets', formRoutes, { attach: true })
expect(press(document.body, '?', { shiftKey: true }).defaultPrevented).toBe(true)
await settle()
expect(helpRows()).toEqual(['/', '?'])
})
it('lists the shortcuts of an update form and closes on Esc without leaving the form', async () => {
const { router } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
press(document.body, '?', { shiftKey: true })
await settle()
expect(helpRows()).toEqual(['mod+s', 'mod+enter', 'escape', '?'])
// The help dialog is the innermost modal: Ctrl+S saves nothing under it.
expect(ctrl('s').defaultPrevented).toBe(true)
press(document.activeElement ?? document.body, 'Escape')
await settle()
expect(document.body.querySelector('[data-shortcut-help]')).toBeNull()
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/1')
})
it('lists Create and new on a create form', async () => {
await mountApp('/acme/demo/widgets/create', formRoutes, { attach: true })
press(document.body, '?', { shiftKey: true })
await settle()
expect(helpRows()).toEqual(['mod+s', 'mod+enter', 'mod+shift+s', 'escape', '?'])
})
it('does not open the help while typing', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
expect(press(wrapper.get('#field-name').element, '?', { shiftKey: true }).defaultPrevented).toBe(false)
await settle()
expect(document.body.querySelector('[data-shortcut-help]')).toBeNull()
})
})